From 2eb274bd01027875484c87cbbf409d82acadb8f7 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Tue, 7 Nov 2023 18:22:19 +0000 Subject: [PATCH 01/53] chore: update bundled-modules 516b3f8 update generic-mqtt to v2.0.3 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index 5d63be3915..516b3f83ea 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 5d63be3915c9ba860674f33f63a4a8c8bc8fb29f +Subproject commit 516b3f83ea1ae6971f4eaa4f2b103e1ab204bb81 From 0cfae13e4fad4741d9a810c0d68e2b742e71a8d5 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 7 Nov 2023 18:52:19 +0000 Subject: [PATCH 02/53] fix: don't require hidden instance config fields to pass validation --- webui/src/Instances/InstanceEditPanel.jsx | 27 ++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/webui/src/Instances/InstanceEditPanel.jsx b/webui/src/Instances/InstanceEditPanel.jsx index a2b5e263ea..7d8fb7d37c 100644 --- a/webui/src/Instances/InstanceEditPanel.jsx +++ b/webui/src/Instances/InstanceEditPanel.jsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useContext, useEffect, useState } from 'react' +import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { LoadingRetryOrError, sandbox, socketEmitPromise, SocketContext, ModulesContext } from '../util' import { CRow, CCol, CButton } from '@coreui/react' import { ColorInputField, DropdownInputField, NumberInputField, TextInputField } from '../Components' @@ -44,6 +44,20 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC const [fieldVisibility, setFieldVisibility] = useState({}) + const invalidFieldNames = useMemo(() => { + const fieldNames = [] + + if (validFields) { + for (const [field, valid] of Object.entries(validFields)) { + if (!valid && fieldVisibility[field] !== false) { + fieldNames.push(field) + } + } + } + + return fieldNames + }, [validFields, fieldVisibility]) + const doCancel = useCallback(() => { doConfigureInstance(null) setConfigFields([]) @@ -54,9 +68,8 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC const newLabel = instanceLabel?.trim() - const isInvalid = Object.entries(validFields).filter(([k, v]) => !v) - if (!isLabelValid(newLabel) || isInvalid.length > 0) { - setError(`Some config fields are not valid: ${isInvalid.map(([k]) => k).join(', ')}`) + if (!isLabelValid(newLabel) || invalidFieldNames.length > 0) { + setError(`Some config fields are not valid: ${invalidFieldNames.join(', ')}`) return } @@ -78,7 +91,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC .catch((e) => { setError(`Failed to save connection config: ${e}`) }) - }, [socket, instanceId, validFields, instanceLabel, instanceConfig, doCancel]) + }, [socket, instanceId, invalidFieldNames, instanceLabel, instanceConfig, doCancel]) useEffect(() => { if (instanceId) { @@ -209,9 +222,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC !v) === false || !isLabelValid(instanceLabel) - } + disabled={!validFields || invalidFieldNames.length > 0 || !isLabelValid(instanceLabel)} onClick={doSave} > Save From cc9a10ae91bf5c0695469a7f8272373160ad4af0 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 7 Nov 2023 22:41:12 +0000 Subject: [PATCH 03/53] fix: setting button png over http broken #2097 --- lib/UI/Express.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/UI/Express.js b/lib/UI/Express.js index 8b9fb1627d..e0679114eb 100644 --- a/lib/UI/Express.js +++ b/lib/UI/Express.js @@ -206,8 +206,7 @@ class UIExpress extends Express { res.send('png64 must be a base64 encoded png file') return } else { - const data = req.query.png64.replace(/^.*base64,/, '') - newFields.png64 = data + newFields.png64 = req.query.png64 } } From 6d6debe36fefb96593a236dd2ab7de634007d26f Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Wed, 8 Nov 2023 01:17:12 +0000 Subject: [PATCH 04/53] chore: update bundled-modules c1d8776 update panasonic-projector to v3.2.0 f971763 update middlethings-middlecontrol to v2.1.0 7c853f6 update youtube-live to v2.4.1 3816c25 update bmd-hyperdeck to v2.2.0 90db953 update bmd-videohub to v2.2.0 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index 516b3f83ea..c1d8776daa 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 516b3f83ea1ae6971f4eaa4f2b103e1ab204bb81 +Subproject commit c1d8776daa57df5be44c69ea1efb1ba05bdfc650 From f278d41f9dfe0e1f81971d433762cd5b7f299b9c Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Wed, 8 Nov 2023 06:23:10 +0000 Subject: [PATCH 05/53] chore: update bundled-modules 536a6e8 update birddog-cloud to v1.0.0 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index c1d8776daa..536a6e8b6e 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit c1d8776daa57df5be44c69ea1efb1ba05bdfc650 +Subproject commit 536a6e8b6efebbd3eb518940f07221a06b03f21d From 41594b2906a592b219520b80c6aa33752d9e5d2d Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Wed, 8 Nov 2023 18:22:38 +0000 Subject: [PATCH 06/53] chore: update bundled-modules a14e556 update mixtech-theatremix to v2.0.0 1954a5a update zinc-oscpoint to 1.0.2 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index 536a6e8b6e..a14e556518 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 536a6e8b6efebbd3eb518940f07221a06b03f21d +Subproject commit a14e55651840fd2cef889e2c7333686d6588ef47 From 81049fdc1857997f631a7fd7fd907c3df782f945 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Thu, 9 Nov 2023 18:22:20 +0000 Subject: [PATCH 07/53] chore: update bundled-modules f8402f5 update behringer-x32 to v3.1.0 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index a14e556518..f8402f5ae5 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit a14e55651840fd2cef889e2c7333686d6588ef47 +Subproject commit f8402f5ae58e0ad3b487edda635139850c8da44f From e24695f0509291ca9b36b2afc590870a6a1e0760 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 10 Nov 2023 23:40:44 +0000 Subject: [PATCH 08/53] chore: fix typo in osc protocol docs --- docs/5_remote_control/osc_control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/5_remote_control/osc_control.md b/docs/5_remote_control/osc_control.md index f0bcba60f5..e03fc03260 100644 --- a/docs/5_remote_control/osc_control.md +++ b/docs/5_remote_control/osc_control.md @@ -14,7 +14,7 @@ Remote triggering can be done by sending OSC commands to port `12321`. _Change color of text on button_ - `/style/text/ ` _Change text on a button_ -- `/custom-variable/ ` +- `/custom-variable//value ` _Change custom variable value_ - `/rescan 1` _Make Companion rescan for newly attached USB surfaces_ From bf5dc8b608780f0c2107161668e2b2d038164fe0 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 10 Nov 2023 23:54:25 +0000 Subject: [PATCH 09/53] chore: add note about logs on disk to log view, reword and tidy export support bundle text --- webui/src/ConnectionDebug.jsx | 11 +++++++++-- webui/src/LogPanel.jsx | 18 ++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/webui/src/ConnectionDebug.jsx b/webui/src/ConnectionDebug.jsx index 90a78e4191..34e69598f8 100644 --- a/webui/src/ConnectionDebug.jsx +++ b/webui/src/ConnectionDebug.jsx @@ -8,6 +8,13 @@ import AutoSizer from 'react-virtualized-auto-sizer' import { useElementSize } from 'usehooks-ts' import { stringify as csvStringify } from 'csv-stringify/sync' +const LogsOnDiskInfoLine = { + time: null, + level: 'debug', + source: 'log', + message: 'Only recent lines are shown here, nothing is persisted', +} + export function ConnectionDebug() { const socket = useContext(SocketContext) @@ -266,7 +273,7 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW function Row({ style, index }) { const rowRef = useRef({}) - const h = messages[index] + const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] useEffect(() => { if (rowRef.current) { @@ -289,7 +296,7 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW {({ height, width }) => ( loadConfig()) @@ -39,7 +46,10 @@ export const LogPanel = memo(function LogPanel() { const exportSupportModal = useCallback(() => { exportRef.current.show( 'Export Support Bundle', - 'Are you sure you want to export your configuration and logs? This may contain sensitive information, such as connection information to online services. It is not recommended to post this publicly, rather you should send it privately to the necessary party.', + [ + 'This packages up your recent Companion logs, configuration and backups.', + 'This may contain sensitive information, such as connection information to online services. It is not recommended to post this publicly, rather you should send it privately to a trusted party who is able to help you with an issue.', + ], 'Export', () => { window.open('/int/export/support') @@ -227,7 +237,7 @@ function LogPanelContents({ config }) { function Row({ style, index }) { const rowRef = useRef({}) - const h = messages[index] + const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] useEffect(() => { if (rowRef.current) { @@ -250,7 +260,7 @@ function LogPanelContents({ config }) { {({ height, width }) => ( { - const time_format = dayjs(h.time).format('YY.MM.DD HH:mm:ss') + const time_format = h.time === null ? ' ' : dayjs(h.time).format('YY.MM.DD HH:mm:ss') return (
{time_format} {h.source}: {h.message} From 758f95aaf492581ddf36cf4addbd64fbd71c199b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 00:14:26 +0000 Subject: [PATCH 10/53] fix: allow clearing png image set in boolean feedback #2606 --- lib/Controls/Fragments/FragmentFeedbacks.js | 2 +- webui/src/Controls/FeedbackEditor.jsx | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Controls/Fragments/FragmentFeedbacks.js b/lib/Controls/Fragments/FragmentFeedbacks.js index 3e4126c90a..fa64731346 100644 --- a/lib/Controls/Fragments/FragmentFeedbacks.js +++ b/lib/Controls/Fragments/FragmentFeedbacks.js @@ -431,7 +431,7 @@ export default class FragmentFeedbacks extends CoreBase { feedbackSetStyleValue(id, key, value) { if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') - if (key === 'png64') { + if (key === 'png64' && value !== null) { if (!value.match(/data:.*?image\/png/)) { return false } diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.jsx index 7b7877d35f..2f7b29d287 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.jsx @@ -566,6 +566,13 @@ function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }) { }, [setStylePropsValue] ) + const clearPng = useCallback(() => { + setPngError(null) + setStylePropsValue('png64', null).catch((e) => { + console.error('Failed to clear png', e) + setPngError('Failed to clear png') + }) + }, [setStylePropsValue]) const currentStyle = useMemo(() => feedback?.style || {}, [feedback?.style]) const showField = useCallback((id) => id in currentStyle, [currentStyle]) @@ -584,6 +591,7 @@ function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }) { values={currentStyle} setValueInner={setValue} setPng={setPng} + clearPng={clearPng} setPngError={clearPngError} showField={showField} /> From b86207b78ae3741978f846df992d138bfee1e12e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 00:14:56 +0000 Subject: [PATCH 11/53] fix: change png image size hint to a info icon #2606 --- webui/src/Controls/ButtonStyleConfig.jsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/webui/src/Controls/ButtonStyleConfig.jsx b/webui/src/Controls/ButtonStyleConfig.jsx index 24f8774851..0e3b22bfe8 100644 --- a/webui/src/Controls/ButtonStyleConfig.jsx +++ b/webui/src/Controls/ButtonStyleConfig.jsx @@ -1,6 +1,6 @@ import { CButton, CRow, CCol, CButtonGroup, CForm, CAlert, CInputGroup, CInputGroupAppend } from '@coreui/react' import React, { useCallback, useContext, useMemo, useState } from 'react' -import { socketEmitPromise, SocketContext, UserConfigContext, PreventDefaultHandler } from '../util' +import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' import { AlignmentInputField, ColorInputField, DropdownInputField, PNGInputField, TextInputField } from '../Components' import { FONT_SIZES } from '../Constants' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -123,11 +123,6 @@ export function ButtonStyleConfigFields({ () => setValueInner('textExpression', !values.textExpression), [setValueInner, values.textExpression] ) - const userconfig = useContext(UserConfigContext) - - let pngWidth = 72 - let pngHeight = - values.show_topbar === false || (values.show_topbar === 'default' && userconfig.remove_topbar === true) ? 72 : 58 // this style will be different when you use it in the main dialog compared to in the feedback editor. const specialStyleForButtonEditor = useMemo( @@ -246,7 +241,7 @@ export function ButtonStyleConfigFields({ {showField2('png64') && (
Date: Sat, 11 Nov 2023 11:12:37 +0000 Subject: [PATCH 12/53] chore: remove some replaced legacy modules --- module-legacy/package.json | 4 ---- module-legacy/yarn.lock | 18 ------------------ tools/check_for_unused_legacy_modules.mjs | 12 ++++++++++++ 3 files changed, 12 insertions(+), 22 deletions(-) create mode 100644 tools/check_for_unused_legacy_modules.mjs diff --git a/module-legacy/package.json b/module-legacy/package.json index 0ea39b181a..2552450cce 100644 --- a/module-legacy/package.json +++ b/module-legacy/package.json @@ -29,12 +29,10 @@ "companion-module-analogway-vio": "github:bitfocus/companion-module-analogway-vio#v1.0.3", "companion-module-arkaos-mediamaster": "github:bitfocus/companion-module-arkaos-mediamaster#v1.0.5", "companion-module-aten-matrix": "github:bitfocus/companion-module-aten-matrix#v1.0.1", - "companion-module-audiostrom-liveprofessor": "github:bitfocus/companion-module-audiostrom-liveprofessor#v1.0.3", "companion-module-avishop-hdbaset-matrix": "github:bitfocus/companion-module-avishop-hdbaset-matrix#v1.0.3", "companion-module-avolites-ai": "github:bitfocus/companion-module-avolites-ai#v1.0.3", "companion-module-avolites-titan": "github:bitfocus/companion-module-avolites-titan#v1.2.1", "companion-module-avproconnect-acmx1616-auhd": "github:bitfocus/companion-module-avproconnect-acmx1616-auhd#v1.0.1", - "companion-module-avstumpfl-pixera": "github:bitfocus/companion-module-avstumpfl-pixera#v1.0.4", "companion-module-barco-clickshare": "github:bitfocus/companion-module-barco-clickshare#cda8edc6a064cc01316061fdbd12302cd88d4489", "companion-module-barco-dcs": "github:bitfocus/companion-module-barco-dcs#v1.0.6", "companion-module-barco-dp": "github:bitfocus/companion-module-barco-dp#v1.1.3", @@ -45,7 +43,6 @@ "companion-module-barco-matrixpro": "github:bitfocus/companion-module-barco-matrixpro#v1.1.1", "companion-module-behringer-wing": "github:bitfocus/companion-module-behringer-wing#v1.0.8", "companion-module-biamp-tesira": "github:bitfocus/companion-module-biamp-tesira#v1.0.0", - "companion-module-birddog-central": "github:bitfocus/companion-module-birddog-central#v1.0.0", "companion-module-bitfocus-snapshot": "github:bitfocus/companion-module-bitfocus-snapshot#v0.0.7", "companion-module-blackbird-hdmimatrix": "github:bitfocus/companion-module-blackbird-hdmimatrix#v1.0.3", "companion-module-blackbox-boxilla": "github:bitfocus/companion-module-blackbox-boxilla#v1.0.4", @@ -116,7 +113,6 @@ "companion-module-imagine-lrc": "github:bitfocus/companion-module-imagine-lrc#v1.1.0", "companion-module-ioversal-vertex": "github:bitfocus/companion-module-ioversal-vertex#v0.0.2", "companion-module-ipl-ocp": "github:bitfocus/companion-module-ipl-ocp#v2.0.6", - "companion-module-jamesholt-x32tc": "github:bitfocus/companion-module-jamesholt-x32tc#v1.0.8", "companion-module-joy-playdeck": "github:bitfocus/companion-module-joy-playdeck#v1.0.2", "companion-module-jvc-ptz": "github:bitfocus/companion-module-jvc-ptz#v1.1.0", "companion-module-kiloview-ndi": "github:bitfocus/companion-module-kiloview-ndi#v1.0.2", diff --git a/module-legacy/yarn.lock b/module-legacy/yarn.lock index 5d7a383b4c..3f4aa324dc 100644 --- a/module-legacy/yarn.lock +++ b/module-legacy/yarn.lock @@ -1630,10 +1630,6 @@ commist@^1.0.0: version "1.0.1" resolved "https://codeload.github.com/bitfocus/companion-module-aten-matrix/tar.gz/6c66b5375c554062ec382cea010a0a39c9e26fee" -"companion-module-audiostrom-liveprofessor@github:bitfocus/companion-module-audiostrom-liveprofessor#v1.0.3": - version "1.0.3" - resolved "https://codeload.github.com/bitfocus/companion-module-audiostrom-liveprofessor/tar.gz/e0bb33619f0f009d1544f83e48c6ef8426fefc0c" - "companion-module-avishop-hdbaset-matrix@github:bitfocus/companion-module-avishop-hdbaset-matrix#v1.0.3": version "1.0.3" resolved "https://codeload.github.com/bitfocus/companion-module-avishop-hdbaset-matrix/tar.gz/ca912f5076dc948dd6f0481181ecce1b66aff67a" @@ -1652,10 +1648,6 @@ commist@^1.0.0: version "1.0.1" resolved "https://codeload.github.com/bitfocus/companion-module-avproconnect-acmx1616-auhd/tar.gz/43ce1b0ee6e3c454a22700fd99af05d57305ced3" -"companion-module-avstumpfl-pixera@github:bitfocus/companion-module-avstumpfl-pixera#v1.0.4": - version "1.0.4" - resolved "https://codeload.github.com/bitfocus/companion-module-avstumpfl-pixera/tar.gz/e745b28a9997e5e0fc3fdcee848758be3f8f1acd" - "companion-module-barco-clickshare@github:bitfocus/companion-module-barco-clickshare#cda8edc6a064cc01316061fdbd12302cd88d4489": version "1.0.4" resolved "https://codeload.github.com/bitfocus/companion-module-barco-clickshare/tar.gz/cda8edc6a064cc01316061fdbd12302cd88d4489" @@ -1699,12 +1691,6 @@ commist@^1.0.0: version "1.0.0" resolved "https://codeload.github.com/bitfocus/companion-module-biamp-tesira/tar.gz/c8a46fdd19d18169980a8ffc24e63d6b1f68ccf2" -"companion-module-birddog-central@github:bitfocus/companion-module-birddog-central#v1.0.0": - version "1.0.0" - resolved "https://codeload.github.com/bitfocus/companion-module-birddog-central/tar.gz/c7844722b36cd6eed07940466281b9c522a35d5f" - dependencies: - node-fetch "^2.6.7" - "companion-module-bitfocus-snapshot@github:bitfocus/companion-module-bitfocus-snapshot#v0.0.7": version "0.0.7" resolved "https://codeload.github.com/bitfocus/companion-module-bitfocus-snapshot/tar.gz/8dc658819c7936389d39d7f81a5f21fb52539bea" @@ -2033,10 +2019,6 @@ commist@^1.0.0: tslib "^2.4.1" typed-emitter "^2.1.0" -"companion-module-jamesholt-x32tc@github:bitfocus/companion-module-jamesholt-x32tc#v1.0.8": - version "1.0.8" - resolved "https://codeload.github.com/bitfocus/companion-module-jamesholt-x32tc/tar.gz/1d11531a2374f5e0041f7306509f1a17cc549166" - "companion-module-joy-playdeck@github:bitfocus/companion-module-joy-playdeck#v1.0.2": version "1.0.2" resolved "https://codeload.github.com/bitfocus/companion-module-joy-playdeck/tar.gz/f854d9662c452c2715c79bf142b257a3790fa14f" diff --git a/tools/check_for_unused_legacy_modules.mjs b/tools/check_for_unused_legacy_modules.mjs new file mode 100644 index 0000000000..9bd75b25cf --- /dev/null +++ b/tools/check_for_unused_legacy_modules.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env zx + +const pkgJsonStr = await fs.readFile('module-legacy/package.json') +const pkgJson = JSON.parse(pkgJsonStr.toString()) + +const PREFIX = 'companion-module-' + +for (const name of Object.keys(pkgJson.dependencies)) { + if (!name.startsWith(PREFIX)) continue + + if (fs.existsSync(path.join('bundled-modules', name.slice(PREFIX.length)))) console.log(name) +} From 0e58faaeaa3563f2e7e5ef11144e1a1e4b24674f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 11:28:42 +0000 Subject: [PATCH 13/53] chore: restore beta version number --- launcher/Paths.cjs | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/launcher/Paths.cjs b/launcher/Paths.cjs index ec9945d0ca..cd38e50a70 100644 --- a/launcher/Paths.cjs +++ b/launcher/Paths.cjs @@ -5,7 +5,6 @@ const ConfigReleaseDirs = [ 'v3.0', 'v3.1', 'v3.2', - 'v3.99', ] module.exports = { diff --git a/package.json b/package.json index 82fc265468..9b2e7beb2f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "companion", - "version": "3.99.0", + "version": "3.2.0", "description": "Companion", "main": "main.js", "type": "module", From 25375d2b0743e0cdb59e1ef58c5e75fad4b1199e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 11:29:36 +0000 Subject: [PATCH 14/53] chore: remove log line --- lib/Service/Https.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Service/Https.js b/lib/Service/Https.js index 8ae4345aa3..8b66090e77 100644 --- a/lib/Service/Https.js +++ b/lib/Service/Https.js @@ -150,7 +150,6 @@ class ServiceHttps extends ServiceBase { * @param {ServiceHttpsCredentials} credentials - the certificate information */ startServer(credentials) { - console.log('start https', this.port, this.bindIP) try { this.server = _https.createServer(credentials, this.express.app) this.server.on('error', this.handleSocketError.bind(this)) From 02f55df84ae4d4d87dfbc31bc092c89c15138456 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Sat, 11 Nov 2023 12:31:13 +0000 Subject: [PATCH 15/53] chore: update bundled-modules 2ca444e update bmd-atem to v3.6.0 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index f8402f5ae5..2ca444e563 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit f8402f5ae58e0ad3b487edda635139850c8da44f +Subproject commit 2ca444e56359f487c881bdd049451c216813af7a From aa8c1c2cf86294e6e7c583e51039240c4e83b882 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 12:35:26 +0000 Subject: [PATCH 16/53] chore: fill out expression function unit tests --- test/expressions-functions.test.js | 394 +++++++++++++++++++---------- 1 file changed, 266 insertions(+), 128 deletions(-) diff --git a/test/expressions-functions.test.js b/test/expressions-functions.test.js index b297813212..f10d2d4ff8 100644 --- a/test/expressions-functions.test.js +++ b/test/expressions-functions.test.js @@ -1,152 +1,290 @@ import { ExpressionFunctions } from '../lib/Shared/Expression/ExpressionFunctions.js' describe('functions', () => { - it('round', () => { - expect(ExpressionFunctions.round(9.99)).toBe(10) - expect(ExpressionFunctions.round('9.99')).toBe(10) - expect(ExpressionFunctions.round(-0)).toBe(-0) - - expect(ExpressionFunctions.round('test')).toBe(NaN) - expect(ExpressionFunctions.round('true')).toBe(NaN) - expect(ExpressionFunctions.round(undefined)).toBe(NaN) - expect(ExpressionFunctions.round(true)).toBe(1) - expect(ExpressionFunctions.round(false)).toBe(0) - }) + describe('number', () => { + it('round', () => { + expect(ExpressionFunctions.round(9.99)).toBe(10) + expect(ExpressionFunctions.round('9.99')).toBe(10) + expect(ExpressionFunctions.round(-0)).toBe(-0) - it('floor', () => { - expect(ExpressionFunctions.floor(9.99)).toBe(9) - expect(ExpressionFunctions.floor('9.99')).toBe(9) - expect(ExpressionFunctions.floor(-0)).toBe(-0) + expect(ExpressionFunctions.round('test')).toBe(NaN) + expect(ExpressionFunctions.round('true')).toBe(NaN) + expect(ExpressionFunctions.round(undefined)).toBe(NaN) + expect(ExpressionFunctions.round(true)).toBe(1) + expect(ExpressionFunctions.round(false)).toBe(0) + }) - expect(ExpressionFunctions.floor('test')).toBe(NaN) - expect(ExpressionFunctions.floor('true')).toBe(NaN) - expect(ExpressionFunctions.floor(undefined)).toBe(NaN) - expect(ExpressionFunctions.floor(true)).toBe(1) - expect(ExpressionFunctions.floor(false)).toBe(0) - }) + it('floor', () => { + expect(ExpressionFunctions.floor(9.99)).toBe(9) + expect(ExpressionFunctions.floor('9.99')).toBe(9) + expect(ExpressionFunctions.floor(-0)).toBe(-0) - it('ceil', () => { - expect(ExpressionFunctions.ceil(9.99)).toBe(10) - expect(ExpressionFunctions.ceil('9.99')).toBe(10) - expect(ExpressionFunctions.ceil(-0)).toBe(-0) + expect(ExpressionFunctions.floor('test')).toBe(NaN) + expect(ExpressionFunctions.floor('true')).toBe(NaN) + expect(ExpressionFunctions.floor(undefined)).toBe(NaN) + expect(ExpressionFunctions.floor(true)).toBe(1) + expect(ExpressionFunctions.floor(false)).toBe(0) + }) - expect(ExpressionFunctions.ceil('test')).toBe(NaN) - expect(ExpressionFunctions.ceil('true')).toBe(NaN) - expect(ExpressionFunctions.ceil(undefined)).toBe(NaN) - expect(ExpressionFunctions.ceil(true)).toBe(1) - expect(ExpressionFunctions.ceil(false)).toBe(0) - }) + it('ceil', () => { + expect(ExpressionFunctions.ceil(9.99)).toBe(10) + expect(ExpressionFunctions.ceil('9.99')).toBe(10) + expect(ExpressionFunctions.ceil(-0)).toBe(-0) - it('abs', () => { - expect(ExpressionFunctions.abs(9.99)).toBe(9.99) - expect(ExpressionFunctions.abs('-9.99')).toBe(9.99) - expect(ExpressionFunctions.abs(-0)).toBe(0) + expect(ExpressionFunctions.ceil('test')).toBe(NaN) + expect(ExpressionFunctions.ceil('true')).toBe(NaN) + expect(ExpressionFunctions.ceil(undefined)).toBe(NaN) + expect(ExpressionFunctions.ceil(true)).toBe(1) + expect(ExpressionFunctions.ceil(false)).toBe(0) + }) - expect(ExpressionFunctions.abs('test')).toBe(NaN) - expect(ExpressionFunctions.abs('true')).toBe(NaN) - expect(ExpressionFunctions.abs(undefined)).toBe(NaN) - expect(ExpressionFunctions.abs(true)).toBe(1) - expect(ExpressionFunctions.abs(false)).toBe(0) - }) + it('abs', () => { + expect(ExpressionFunctions.abs(9.99)).toBe(9.99) + expect(ExpressionFunctions.abs('-9.99')).toBe(9.99) + expect(ExpressionFunctions.abs(-0)).toBe(0) - it('fromRadix', () => { - expect(ExpressionFunctions.fromRadix('11', 16)).toBe(17) - expect(ExpressionFunctions.fromRadix('11', 2)).toBe(3) - expect(ExpressionFunctions.fromRadix('f', 16)).toBe(15) - expect(ExpressionFunctions.fromRadix('11')).toBe(11) - }) + expect(ExpressionFunctions.abs('test')).toBe(NaN) + expect(ExpressionFunctions.abs('true')).toBe(NaN) + expect(ExpressionFunctions.abs(undefined)).toBe(NaN) + expect(ExpressionFunctions.abs(true)).toBe(1) + expect(ExpressionFunctions.abs(false)).toBe(0) + }) - it('toRadix', () => { - expect(ExpressionFunctions.toRadix(11, 16)).toBe('b') - expect(ExpressionFunctions.toRadix(11, 2)).toBe('1011') - expect(ExpressionFunctions.toRadix(9, 16)).toBe('9') - expect(ExpressionFunctions.toRadix(11)).toBe('11') - }) + it('fromRadix', () => { + expect(ExpressionFunctions.fromRadix('11', 16)).toBe(17) + expect(ExpressionFunctions.fromRadix('11', 2)).toBe(3) + expect(ExpressionFunctions.fromRadix('f', 16)).toBe(15) + expect(ExpressionFunctions.fromRadix('11')).toBe(11) + }) - it('toFixed', () => { - expect(ExpressionFunctions.toFixed(Math.PI, 3)).toBe('3.142') - expect(ExpressionFunctions.toFixed(Math.PI, 2)).toBe('3.14') - expect(ExpressionFunctions.toFixed(-Math.PI, 2)).toBe('-3.14') - expect(ExpressionFunctions.toFixed(Math.PI)).toBe('3') - expect(ExpressionFunctions.toFixed(5, 2)).toBe('5.00') - expect(ExpressionFunctions.toFixed(Math.PI, -2)).toBe('3') - }) + it('toRadix', () => { + expect(ExpressionFunctions.toRadix(11, 16)).toBe('b') + expect(ExpressionFunctions.toRadix(11, 2)).toBe('1011') + expect(ExpressionFunctions.toRadix(9, 16)).toBe('9') + expect(ExpressionFunctions.toRadix(11)).toBe('11') + }) - it('isNumber', () => { - expect(ExpressionFunctions.isNumber(11)).toBe(true) - expect(ExpressionFunctions.isNumber('99')).toBe(true) - expect(ExpressionFunctions.isNumber('true')).toBe(false) - expect(ExpressionFunctions.isNumber('')).toBe(true) - expect(ExpressionFunctions.isNumber(undefined)).toBe(false) - }) + it('toFixed', () => { + expect(ExpressionFunctions.toFixed(Math.PI, 3)).toBe('3.142') + expect(ExpressionFunctions.toFixed(Math.PI, 2)).toBe('3.14') + expect(ExpressionFunctions.toFixed(-Math.PI, 2)).toBe('-3.14') + expect(ExpressionFunctions.toFixed(Math.PI)).toBe('3') + expect(ExpressionFunctions.toFixed(5, 2)).toBe('5.00') + expect(ExpressionFunctions.toFixed(Math.PI, -2)).toBe('3') + }) - it('timestampToSeconds', () => { - expect(ExpressionFunctions.timestampToSeconds('00:00:11')).toBe(11) - expect(ExpressionFunctions.timestampToSeconds('00:16:39')).toBe(999) - expect(ExpressionFunctions.timestampToSeconds('02:46:39')).toBe(9999) - expect(ExpressionFunctions.timestampToSeconds('342:56:07')).toBe(1234567) + it('isNumber', () => { + expect(ExpressionFunctions.isNumber(11)).toBe(true) + expect(ExpressionFunctions.isNumber('99')).toBe(true) + expect(ExpressionFunctions.isNumber('true')).toBe(false) + expect(ExpressionFunctions.isNumber('')).toBe(true) + expect(ExpressionFunctions.isNumber(undefined)).toBe(false) + }) - expect(ExpressionFunctions.timestampToSeconds('00:00_11')).toBe(0) - expect(ExpressionFunctions.timestampToSeconds(false)).toBe(0) - expect(ExpressionFunctions.timestampToSeconds(99)).toBe(0) - }) + it('max', () => { + expect(ExpressionFunctions.max()).toBe(Number.NEGATIVE_INFINITY) + expect(ExpressionFunctions.max(9, 1, 3)).toBe(9) + expect(ExpressionFunctions.max(9.9, 1.9)).toBe(9.9) + expect(ExpressionFunctions.max('a', 1, 9)).toBe(NaN) + }) - it('trim', () => { - expect(ExpressionFunctions.trim(11)).toBe('11') - expect(ExpressionFunctions.trim(' 99 ')).toBe('99') - expect(ExpressionFunctions.trim('\t aa \n')).toBe('aa') - expect(ExpressionFunctions.trim('')).toBe('') - expect(ExpressionFunctions.trim(undefined)).toBe('undefined') - expect(ExpressionFunctions.trim(false)).toBe('false') - expect(ExpressionFunctions.trim(true)).toBe('true') - }) + it('min', () => { + expect(ExpressionFunctions.min()).toBe(Number.POSITIVE_INFINITY) + expect(ExpressionFunctions.min(9, 1, 3)).toBe(1) + expect(ExpressionFunctions.min(9.9, 1.9)).toBe(1.9) + expect(ExpressionFunctions.min('a', 1, 9)).toBe(NaN) + }) - it('strlen', () => { - expect(ExpressionFunctions.strlen(11)).toBe(2) - expect(ExpressionFunctions.strlen(' 99 ')).toBe(6) - expect(ExpressionFunctions.strlen('\t aa \n')).toBe(6) - expect(ExpressionFunctions.strlen('')).toBe(0) - expect(ExpressionFunctions.strlen(undefined)).toBe(9) - expect(ExpressionFunctions.strlen(false)).toBe(5) - expect(ExpressionFunctions.strlen(true)).toBe(4) - }) + it('unixNow', () => { + const value = ExpressionFunctions.unixNow() + expect(value / 10).toBeCloseTo(Date.now() / 10, 0) + }) - it('substr', () => { - expect(ExpressionFunctions.substr('abcdef', 2)).toBe('cdef') - expect(ExpressionFunctions.substr('abcdef', -2)).toBe('ef') - expect(ExpressionFunctions.substr('abcdef', 2, 4)).toBe('cd') - expect(ExpressionFunctions.substr('abcdef', 2, -2)).toBe('cd') - expect(ExpressionFunctions.substr('abcdef', -4, -2)).toBe('cd') - expect(ExpressionFunctions.substr('abcdef', 0, 0)).toBe('') - - expect(ExpressionFunctions.substr(11)).toBe('11') - expect(ExpressionFunctions.substr('', 0, 1)).toBe('') - expect(ExpressionFunctions.substr(undefined)).toBe('undefined') - expect(ExpressionFunctions.substr(false)).toBe('false') - expect(ExpressionFunctions.substr(true)).toBe('true') - }) + it('timestampToSeconds', () => { + expect(ExpressionFunctions.timestampToSeconds('00:00:11')).toBe(11) + expect(ExpressionFunctions.timestampToSeconds('00:16:39')).toBe(999) + expect(ExpressionFunctions.timestampToSeconds('02:46:39')).toBe(9999) + expect(ExpressionFunctions.timestampToSeconds('342:56:07')).toBe(1234567) + + expect(ExpressionFunctions.timestampToSeconds('00:00_11')).toBe(0) + expect(ExpressionFunctions.timestampToSeconds(false)).toBe(0) + expect(ExpressionFunctions.timestampToSeconds(99)).toBe(0) + }) + + it('randomInt', () => { + for (let i = 0; i < 50; i++) { + const result = ExpressionFunctions.randomInt() + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThanOrEqual(10) + } - it('bool', () => { - expect(ExpressionFunctions.bool(11)).toBe(true) - expect(ExpressionFunctions.bool('99')).toBe(true) - expect(ExpressionFunctions.bool(0)).toBe(false) - expect(ExpressionFunctions.bool('0')).toBe(false) - expect(ExpressionFunctions.bool(true)).toBe(true) - expect(ExpressionFunctions.bool('true')).toBe(true) - expect(ExpressionFunctions.bool(false)).toBe(false) - expect(ExpressionFunctions.bool('false')).toBe(false) - expect(ExpressionFunctions.bool('')).toBe(false) - expect(ExpressionFunctions.bool(undefined)).toBe(false) + for (let i = 0; i < 50; i++) { + const result = ExpressionFunctions.randomInt(-10, '5') + expect(result).toBeGreaterThanOrEqual(-10) + expect(result).toBeLessThanOrEqual(5) + } + }) }) - it('secondsToTimestamp', () => { - expect(ExpressionFunctions.secondsToTimestamp(11)).toBe('00:00:11') - expect(ExpressionFunctions.secondsToTimestamp(999)).toBe('00:16:39') - expect(ExpressionFunctions.secondsToTimestamp(9999)).toBe('02:46:39') - expect(ExpressionFunctions.secondsToTimestamp(1234567)).toBe('342:56:07') + describe('string', () => { + it('trim', () => { + expect(ExpressionFunctions.trim(11)).toBe('11') + expect(ExpressionFunctions.trim(' 99 ')).toBe('99') + expect(ExpressionFunctions.trim('\t aa \n')).toBe('aa') + expect(ExpressionFunctions.trim('')).toBe('') + expect(ExpressionFunctions.trim(undefined)).toBe('undefined') + expect(ExpressionFunctions.trim(false)).toBe('false') + expect(ExpressionFunctions.trim(true)).toBe('true') + }) + + it('strlen', () => { + expect(ExpressionFunctions.strlen(11)).toBe(2) + expect(ExpressionFunctions.strlen(' 99 ')).toBe(6) + expect(ExpressionFunctions.strlen('\t aa \n')).toBe(6) + expect(ExpressionFunctions.strlen('')).toBe(0) + expect(ExpressionFunctions.strlen(undefined)).toBe(9) + expect(ExpressionFunctions.strlen(false)).toBe(5) + expect(ExpressionFunctions.strlen(true)).toBe(4) + }) + + it('substr', () => { + expect(ExpressionFunctions.substr('abcdef', 2)).toBe('cdef') + expect(ExpressionFunctions.substr('abcdef', -2)).toBe('ef') + expect(ExpressionFunctions.substr('abcdef', 2, 4)).toBe('cd') + expect(ExpressionFunctions.substr('abcdef', 2, -2)).toBe('cd') + expect(ExpressionFunctions.substr('abcdef', -4, -2)).toBe('cd') + expect(ExpressionFunctions.substr('abcdef', 0, 0)).toBe('') + + expect(ExpressionFunctions.substr(11)).toBe('11') + expect(ExpressionFunctions.substr('', 0, 1)).toBe('') + expect(ExpressionFunctions.substr(undefined)).toBe('undefined') + expect(ExpressionFunctions.substr(false)).toBe('false') + expect(ExpressionFunctions.substr(true)).toBe('true') + }) + + it('concat', () => { + expect(ExpressionFunctions.concat()).toBe('') + expect(ExpressionFunctions.concat(9, 'ab')).toBe('9ab') + expect(ExpressionFunctions.concat('ab', 9)).toBe('ab9') + expect(ExpressionFunctions.concat(1, 9)).toBe('19') + expect(ExpressionFunctions.concat(false)).toBe('false') + }) + + it('includes', () => { + expect(ExpressionFunctions.includes(912, 12)).toBe(true) + expect(ExpressionFunctions.includes(912, '91')).toBe(true) + expect(ExpressionFunctions.includes(912, '92')).toBe(false) + expect(ExpressionFunctions.includes(false, 'al')).toBe(true) + expect(ExpressionFunctions.includes(false, 'tru')).toBe(false) + expect(ExpressionFunctions.includes('something else', 'ng el')).toBe(true) + expect(ExpressionFunctions.includes('somethingelse', 'ng el')).toBe(false) + }) + + it('indexOf', () => { + expect(ExpressionFunctions.indexOf(912, 12)).toBe(1) + expect(ExpressionFunctions.indexOf(912, '91')).toBe(0) + expect(ExpressionFunctions.indexOf(912, '92')).toBe(-1) + expect(ExpressionFunctions.indexOf(false, 'al')).toBe(1) + expect(ExpressionFunctions.indexOf(false, 'tru')).toBe(-1) + expect(ExpressionFunctions.indexOf('something else', 'ng el')).toBe(7) + expect(ExpressionFunctions.indexOf('somethingelse', 'ng el')).toBe(-1) + expect(ExpressionFunctions.indexOf('1234512345', '34')).toBe(2) + }) + + it('lastIndexOf', () => { + expect(ExpressionFunctions.lastIndexOf(912, 12)).toBe(1) + expect(ExpressionFunctions.lastIndexOf(912, '91')).toBe(0) + expect(ExpressionFunctions.lastIndexOf(912, '92')).toBe(-1) + expect(ExpressionFunctions.lastIndexOf(false, 'al')).toBe(1) + expect(ExpressionFunctions.lastIndexOf(false, 'tru')).toBe(-1) + expect(ExpressionFunctions.lastIndexOf('something else', 'ng el')).toBe(7) + expect(ExpressionFunctions.lastIndexOf('somethingelse', 'ng el')).toBe(-1) + expect(ExpressionFunctions.lastIndexOf('1234512345', '34')).toBe(7) + }) + + it('toUpperCase', () => { + expect(ExpressionFunctions.toUpperCase(11)).toBe('11') + expect(ExpressionFunctions.toUpperCase('anoNs2')).toBe('ANONS2') + expect(ExpressionFunctions.toUpperCase(undefined)).toBe('UNDEFINED') + expect(ExpressionFunctions.toUpperCase(false)).toBe('FALSE') + expect(ExpressionFunctions.toUpperCase(true)).toBe('TRUE') + }) + + it('toLowerCase', () => { + expect(ExpressionFunctions.toLowerCase(11)).toBe('11') + expect(ExpressionFunctions.toLowerCase('anoNs2')).toBe('anons2') + expect(ExpressionFunctions.toLowerCase(undefined)).toBe('undefined') + expect(ExpressionFunctions.toLowerCase(false)).toBe('false') + expect(ExpressionFunctions.toLowerCase(true)).toBe('true') + }) + + it('replaceAll', () => { + expect(ExpressionFunctions.replaceAll(11, 1, 2)).toBe('22') + expect(ExpressionFunctions.replaceAll(false, 'a', false)).toBe('ffalselse') + expect(ExpressionFunctions.replaceAll(true, 'e', true)).toBe('trutrue') + }) + + it('secondsToTimestamp', () => { + expect(ExpressionFunctions.secondsToTimestamp(11)).toBe('00:00:11') + expect(ExpressionFunctions.secondsToTimestamp(999)).toBe('00:16:39') + expect(ExpressionFunctions.secondsToTimestamp(9999)).toBe('02:46:39') + expect(ExpressionFunctions.secondsToTimestamp(1234567)).toBe('342:56:07') + + expect(ExpressionFunctions.secondsToTimestamp('99')).toBe('00:01:39') + expect(ExpressionFunctions.secondsToTimestamp(false)).toBe('00:00:00') + expect(ExpressionFunctions.secondsToTimestamp(-11)).toBe('00:00:00') + + // hh:mm:ss + expect(ExpressionFunctions.secondsToTimestamp(11, 'hh:mm:ss')).toBe('00:00:11') + expect(ExpressionFunctions.secondsToTimestamp(9999, 'hh:mm:ss')).toBe('02:46:39') + expect(ExpressionFunctions.secondsToTimestamp(1234567, 'hh:mm:ss')).toBe('342:56:07') + + // hh:ss + expect(ExpressionFunctions.secondsToTimestamp(11, 'hh:ss')).toBe('00:11') + expect(ExpressionFunctions.secondsToTimestamp(9999, 'hh:ss')).toBe('02:39') + expect(ExpressionFunctions.secondsToTimestamp(1234567, 'hh:ss')).toBe('342:07') + + // hh:mm + expect(ExpressionFunctions.secondsToTimestamp(11, 'hh:mm')).toBe('00:00') + expect(ExpressionFunctions.secondsToTimestamp(9999, 'hh:mm')).toBe('02:46') + expect(ExpressionFunctions.secondsToTimestamp(1234567, 'hh:mm')).toBe('342:56') + + // mm:ss + expect(ExpressionFunctions.secondsToTimestamp(11, 'mm:ss')).toBe('00:11') + expect(ExpressionFunctions.secondsToTimestamp(9999, 'mm:ss')).toBe('46:39') + expect(ExpressionFunctions.secondsToTimestamp(1234567, 'mm:ss')).toBe('56:07') + }) + + it('msToTimestamp', () => { + expect(ExpressionFunctions.msToTimestamp(1100)).toBe('00:01.1') + expect(ExpressionFunctions.msToTimestamp(999123)).toBe('16:39.1') + expect(ExpressionFunctions.msToTimestamp(1234567)).toBe('20:34.5') + + expect(ExpressionFunctions.msToTimestamp('9900')).toBe('00:09.9') + expect(ExpressionFunctions.msToTimestamp(false)).toBe('00:00.0') + expect(ExpressionFunctions.msToTimestamp(-11)).toBe('00:00.0') + + // TODO - format + + // // hh:mm:ss + // expect(ExpressionFunctions.msToTimestamp(11, 'hh:mm:ss')).toBe('00:00:11') + // expect(ExpressionFunctions.msToTimestamp(9999, 'hh:mm:ss')).toBe('02:46:39') + // expect(ExpressionFunctions.msToTimestamp(1234567, 'hh:mm:ss')).toBe('342:56:07') + }) + }) - expect(ExpressionFunctions.secondsToTimestamp('99')).toBe('00:01:39') - expect(ExpressionFunctions.secondsToTimestamp(false)).toBe('00:00:00') - expect(ExpressionFunctions.secondsToTimestamp(-11)).toBe('00:00:00') + describe('boolean', () => { + it('bool', () => { + expect(ExpressionFunctions.bool(11)).toBe(true) + expect(ExpressionFunctions.bool('99')).toBe(true) + expect(ExpressionFunctions.bool(0)).toBe(false) + expect(ExpressionFunctions.bool('0')).toBe(false) + expect(ExpressionFunctions.bool(true)).toBe(true) + expect(ExpressionFunctions.bool('true')).toBe(true) + expect(ExpressionFunctions.bool(false)).toBe(false) + expect(ExpressionFunctions.bool('false')).toBe(false) + expect(ExpressionFunctions.bool('')).toBe(false) + expect(ExpressionFunctions.bool(undefined)).toBe(false) + }) }) }) From c1b9d3dfee4d9044aac36bb9617a38c1a3404865 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 12:42:56 +0000 Subject: [PATCH 17/53] chore: rename bulk of `deviceId` to `surfaceId` --- lib/Controls/ControlBase.js | 4 +- lib/Controls/ControlTypes/Button/Base.js | 10 +- lib/Controls/ControlTypes/Button/Normal.js | 36 +++--- lib/Controls/ControlTypes/PageDown.js | 8 +- lib/Controls/ControlTypes/PageNumber.js | 8 +- lib/Controls/ControlTypes/PageUp.js | 8 +- .../ControlTypes/Triggers/Events/Misc.js | 8 +- lib/Controls/ControlTypes/Triggers/Trigger.js | 2 +- lib/Controls/Controller.js | 32 +++--- lib/Controls/IControlFragments.js | 8 +- lib/Instance/Wrapper.js | 6 +- lib/Internal/Controls.js | 20 ++-- lib/Internal/Surface.js | 14 +-- lib/Service/Api.js | 24 ++-- lib/Service/EmberPlus.js | 6 +- lib/Surface/Controller.js | 104 +++++++++--------- lib/Surface/Handler.js | 34 +++--- webui/src/Surfaces/EditModal.jsx | 100 ++++++++--------- webui/src/Surfaces/index.jsx | 101 +++++++++-------- 19 files changed, 269 insertions(+), 264 deletions(-) diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index c872165d21..4bc93b093e 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -241,12 +241,12 @@ export default class ControlBase extends CoreBase { /** * Execute a press of a control * @param {boolean} _pressed Whether the control is pressed - * @param {string | undefined} _deviceId The surface that intiated this press + * @param {string | undefined} _surfaceId The surface that intiated this press * @param {boolean=} _force Trigger actions even if already in the state * @returns {void} * @access public */ - pressControl(_pressed, _deviceId, _force) { + pressControl(_pressed, _surfaceId, _force) { // To be implemented by subclasses } } diff --git a/lib/Controls/ControlTypes/Button/Base.js b/lib/Controls/ControlTypes/Button/Base.js index 276c4482bd..6abb58a96a 100644 --- a/lib/Controls/ControlTypes/Button/Base.js +++ b/lib/Controls/ControlTypes/Button/Base.js @@ -366,13 +366,13 @@ export default class ButtonControlBase extends ControlBase { /** * Execute a press of this control * @param {boolean} _pressed Whether the control is pressed - * @param {string | undefined} _deviceId The surface that intiated this press + * @param {string | undefined} _surfaceId The surface that intiated this press * @param {boolean=} _force Trigger actions even if already in the state * @returns {void} * @access public * @abstract */ - pressControl(_pressed, _deviceId, _force) { + pressControl(_pressed, _surfaceId, _force) { throw new Error('must be implemented by subclass!') } @@ -423,11 +423,11 @@ export default class ButtonControlBase extends ControlBase { * Set the button as being pushed. * Notifies interested observers * @param {boolean} direction new state - * @param {string=} deviceId device which triggered the change + * @param {string=} surfaceId surface which triggered the change * @returns {boolean} the pushed state changed * @access public */ - setPushed(direction, deviceId) { + setPushed(direction, surfaceId) { const wasPushed = this.pushed // Record is as pressed this.pushed = !!direction @@ -437,7 +437,7 @@ export default class ButtonControlBase extends ControlBase { const location = this.page.getLocationOfControlId(this.controlId) if (location) { - this.services.emberplus.updateBankState(location, this.pushed, deviceId) + this.services.emberplus.updateBankState(location, this.pushed, surfaceId) } this.triggerRedraw() diff --git a/lib/Controls/ControlTypes/Button/Normal.js b/lib/Controls/ControlTypes/Button/Normal.js index 5dad0e3619..8ea24b0eb9 100644 --- a/lib/Controls/ControlTypes/Button/Normal.js +++ b/lib/Controls/ControlTypes/Button/Normal.js @@ -85,7 +85,7 @@ export default class ControlButtonNormal extends ButtonControlBase { #current_step_id = '0' /** - * Button hold state for each surface/deviceId + * Button hold state for each surface * @type {Map} * @access private */ @@ -153,19 +153,19 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * Abort any running 'while held' timers * @access protected - * @param {string | undefined} deviceId + * @param {string | undefined} surfaceId * @returns {void} */ - abortRunningHoldTimers(deviceId) { - if (deviceId) { - const existingState = this.#surfaceHoldState.get(deviceId) + abortRunningHoldTimers(surfaceId) { + if (surfaceId) { + const existingState = this.#surfaceHoldState.get(surfaceId) if (existingState) { // Cancel any pending 'runWhileHeld' timers for (const timer of existingState.timers) { clearTimeout(timer) } } - this.#surfaceHoldState.delete(deviceId) + this.#surfaceHoldState.delete(surfaceId) } else { for (const holdState of this.#surfaceHoldState.values()) { if (holdState) { @@ -656,41 +656,41 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * Execute a press of this control * @param {boolean} pressed Whether the control is pressed - * @param {string | undefined} deviceId The surface that intiated this press + * @param {string | undefined} surfaceId The surface that intiated this press * @param {boolean} force Trigger actions even if already in the state * @returns {void} * @access public */ - pressControl(pressed, deviceId, force) { + pressControl(pressed, surfaceId, force) { const [this_step_id, next_step_id] = this.#validateCurrentStepId() let pressedDuration = 0 let pressedStep = this_step_id /** @type {SurfaceHoldState | undefined} */ let holdState = undefined - if (deviceId) { + if (surfaceId) { // Calculate the press duration, or track when the press started if (pressed) { - this.abortRunningHoldTimers(deviceId) + this.abortRunningHoldTimers(surfaceId) holdState = { pressed: Date.now(), step: this_step_id, timers: [], } - this.#surfaceHoldState.set(deviceId, holdState) + this.#surfaceHoldState.set(surfaceId, holdState) } else { - const state = this.#surfaceHoldState.get(deviceId) + const state = this.#surfaceHoldState.get(surfaceId) if (state) { pressedDuration = Date.now() - state.pressed pressedStep = state.step - this.abortRunningHoldTimers(deviceId) + this.abortRunningHoldTimers(surfaceId) } } } - const changed = this.setPushed(pressed, deviceId) + const changed = this.setPushed(pressed, surfaceId) // if the state has changed, the choose the set to execute if (changed || force) { @@ -735,7 +735,7 @@ export default class ControlButtonNormal extends ButtonControlBase { this.logger.silly('found actions') this.controls.actions.runMultipleActions(actions, this.controlId, this.options.relativeDelay, { - deviceid: deviceId, + surfaceId, }) } } @@ -768,10 +768,10 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * Execute a rotate of this control * @param {boolean} direction Whether the control was rotated to the right - * @param {string | undefined} deviceId The surface that intiated this rotate + * @param {string | undefined} surfaceId The surface that intiated this rotate * @access public */ - rotateControl(direction, deviceId) { + rotateControl(direction, surfaceId) { const [this_step_id] = this.#validateCurrentStepId() const step = this_step_id && this.steps[this_step_id] @@ -785,7 +785,7 @@ export default class ControlButtonNormal extends ButtonControlBase { const enabledActions = actions.filter((act) => !act.disabled) this.controls.actions.runMultipleActions(enabledActions, this.controlId, this.options.relativeDelay, { - deviceid: deviceId, + surfaceId, }) } } diff --git a/lib/Controls/ControlTypes/PageDown.js b/lib/Controls/ControlTypes/PageDown.js index 8953f74809..6b49de0ff9 100644 --- a/lib/Controls/ControlTypes/PageDown.js +++ b/lib/Controls/ControlTypes/PageDown.js @@ -140,12 +140,12 @@ export default class ControlButtonPageDown extends ControlBase { /** * Execute a press of this control * @param {boolean} pressed Whether the control is pressed - * @param {string | undefined} deviceId The surface that intiated this press + * @param {string | undefined} surfaceId The surface that intiated this press * @access public */ - pressControl(pressed, deviceId) { - if (pressed && deviceId) { - this.surfaces.devicePageDown(deviceId) + pressControl(pressed, surfaceId) { + if (pressed && surfaceId) { + this.surfaces.devicePageDown(surfaceId) } } diff --git a/lib/Controls/ControlTypes/PageNumber.js b/lib/Controls/ControlTypes/PageNumber.js index 4cd51a43eb..a6b23c2a98 100644 --- a/lib/Controls/ControlTypes/PageNumber.js +++ b/lib/Controls/ControlTypes/PageNumber.js @@ -141,12 +141,12 @@ export default class ControlButtonPageNumber extends ControlBase { /** * Execute a press of this control * @param {boolean} pressed Whether the control is pressed - * @param {string | undefined} deviceId The surface that intiated this press + * @param {string | undefined} surfaceId The surface that intiated this press * @access public */ - pressControl(pressed, deviceId) { - if (pressed && deviceId) { - this.surfaces.devicePageSet(deviceId, 1) + pressControl(pressed, surfaceId) { + if (pressed && surfaceId) { + this.surfaces.devicePageSet(surfaceId, 1) } } diff --git a/lib/Controls/ControlTypes/PageUp.js b/lib/Controls/ControlTypes/PageUp.js index 3a1dc3a3ee..e903a0160d 100644 --- a/lib/Controls/ControlTypes/PageUp.js +++ b/lib/Controls/ControlTypes/PageUp.js @@ -140,13 +140,13 @@ export default class ControlButtonPageUp extends ControlBase { /** * Execute a press of this control * @param {boolean} pressed Whether the control is pressed - * @param {string | undefined} deviceId The surface that intiated this press + * @param {string | undefined} surfaceId The surface that intiated this press * @returns {void} * @access public */ - pressControl(pressed, deviceId) { - if (pressed && deviceId) { - this.surfaces.devicePageUp(deviceId) + pressControl(pressed, surfaceId) { + if (pressed && surfaceId) { + this.surfaces.devicePageUp(surfaceId) } } diff --git a/lib/Controls/ControlTypes/Triggers/Events/Misc.js b/lib/Controls/ControlTypes/Triggers/Events/Misc.js index 4bc596abfd..b1cd917055 100644 --- a/lib/Controls/ControlTypes/Triggers/Events/Misc.js +++ b/lib/Controls/ControlTypes/Triggers/Events/Misc.js @@ -116,14 +116,14 @@ export default class TriggersEventMisc { * Handler for the control_press event * @param {string} _controlId Id of the control which was pressed * @param {boolean} pressed Whether the control was pressed or depressed. - * @param {string | undefined} deviceId Source of the event + * @param {string | undefined} surfaceId Source of the event * @access private */ - #onControlPress = (_controlId, pressed, deviceId) => { + #onControlPress = (_controlId, pressed, surfaceId) => { if (this.#enabled) { // If the press originated from a trigger, then ignore it - const parsedDeviceId = deviceId ? ParseControlId(deviceId) : undefined - if (parsedDeviceId?.type === 'trigger') return + const parsedSurfaceId = surfaceId ? ParseControlId(surfaceId) : undefined + if (parsedSurfaceId?.type === 'trigger') return let execute = false diff --git a/lib/Controls/ControlTypes/Triggers/Trigger.js b/lib/Controls/ControlTypes/Triggers/Trigger.js index e2866dd500..55db2bf000 100644 --- a/lib/Controls/ControlTypes/Triggers/Trigger.js +++ b/lib/Controls/ControlTypes/Triggers/Trigger.js @@ -432,7 +432,7 @@ export default class ControlTrigger extends ControlBase { this.logger.silly('found actions') this.controls.actions.runMultipleActions(actions, this.controlId, this.options.relativeDelay, { - deviceid: this.controlId, + surfaceId: this.controlId, }) } } diff --git a/lib/Controls/Controller.js b/lib/Controls/Controller.js index d145cfcf76..5d3df4bc2f 100644 --- a/lib/Controls/Controller.js +++ b/lib/Controls/Controller.js @@ -578,17 +578,17 @@ class ControlsController extends CoreBase { /** * @param {import('../Resources/Util.js').ControlLocation} location * @param {boolean} direction - * @param {string} deviceId + * @param {string} surfaceId * @returns {void} */ - (location, direction, deviceId) => { - this.logger.silly(`being told from gui to hot press ${formatLocation(location)} ${direction} ${deviceId}`) - if (!deviceId) throw new Error('Missing deviceId') + (location, direction, surfaceId) => { + this.logger.silly(`being told from gui to hot press ${formatLocation(location)} ${direction} ${surfaceId}`) + if (!surfaceId) throw new Error('Missing surfaceId') const controlId = this.page.getControlIdAt(location) if (!controlId) return - this.pressControl(controlId, direction, `hot:${deviceId}`) + this.pressControl(controlId, direction, `hot:${surfaceId}`) } ) @@ -597,16 +597,16 @@ class ControlsController extends CoreBase { /** * @param {import('../Resources/Util.js').ControlLocation} location * @param {boolean} direction - * @param {string} deviceId + * @param {string} surfaceId * @returns {void} */ - (location, direction, deviceId) => { - this.logger.silly(`being told from gui to hot rotate ${formatLocation(location)} ${direction} ${deviceId}`) + (location, direction, surfaceId) => { + this.logger.silly(`being told from gui to hot rotate ${formatLocation(location)} ${direction} ${surfaceId}`) const controlId = this.page.getControlIdAt(location) if (!controlId) return - this.rotateControl(controlId, direction, deviceId ? `hot:${deviceId}` : undefined) + this.rotateControl(controlId, direction, surfaceId ? `hot:${surfaceId}` : undefined) } ) @@ -1403,17 +1403,17 @@ class ControlsController extends CoreBase { * Execute a press of a control * @param {string} controlId Id of the control * @param {boolean} pressed Whether the control is pressed - * @param {string | undefined} deviceId The surface that intiated this press + * @param {string | undefined} surfaceId The surface that intiated this press * @param {boolean=} force Trigger actions even if already in the state * @returns {boolean} success * @access public */ - pressControl(controlId, pressed, deviceId, force) { + pressControl(controlId, pressed, surfaceId, force) { const control = this.getControl(controlId) if (control) { - this.triggers.emit('control_press', controlId, pressed, deviceId) + this.triggers.emit('control_press', controlId, pressed, surfaceId) - control.pressControl(pressed, deviceId, force) + control.pressControl(pressed, surfaceId, force) return true } @@ -1425,14 +1425,14 @@ class ControlsController extends CoreBase { * Execute rotation of a control * @param {string} controlId Id of the control * @param {boolean} direction Whether the control is rotated to the right - * @param {string | undefined} deviceId The surface that intiated this rotate + * @param {string | undefined} surfaceId The surface that intiated this rotate * @returns {boolean} success * @access public */ - rotateControl(controlId, direction, deviceId) { + rotateControl(controlId, direction, surfaceId) { const control = this.getControl(controlId) if (control && control.supportsActionSets) { - control.rotateControl(direction, deviceId) + control.rotateControl(direction, surfaceId) return true } diff --git a/lib/Controls/IControlFragments.js b/lib/Controls/IControlFragments.js index 6dbde2884f..947898c642 100644 --- a/lib/Controls/IControlFragments.js +++ b/lib/Controls/IControlFragments.js @@ -572,10 +572,10 @@ export class ControlWithActionSets extends ControlBase { /** * Execute a rotate of this control * @param {boolean} _direction Whether the control was rotated to the right - * @param {string | undefined} _deviceId The surface that intiated this rotate + * @param {string | undefined} _surfaceId The surface that intiated this rotate * @access public */ - rotateControl(_direction, _deviceId) { + rotateControl(_direction, _surfaceId) { throw new Error('Not implemented') } } @@ -655,11 +655,11 @@ export class ControlWithPushed extends ControlBase { * Set the button as being pushed. * Notifies interested observers * @param {boolean} _direction new state - * @param {string=} _deviceId device which triggered the change + * @param {string=} _surfaceId device which triggered the change * @returns {boolean} the pushed state changed * @access public */ - setPushed(_direction, _deviceId) { + setPushed(_direction, _surfaceId) { throw new Error('Not implemented') } } diff --git a/lib/Instance/Wrapper.js b/lib/Instance/Wrapper.js index 4cf1b2f23e..889cb52950 100644 --- a/lib/Instance/Wrapper.js +++ b/lib/Instance/Wrapper.js @@ -483,8 +483,8 @@ class SocketEventsHandler { bank: null, }, - surfaceId: extras?.deviceid, - deviceId: extras?.deviceid, + surfaceId: extras?.surfaceId, + deviceId: extras?.surfaceId, }) } catch (/** @type {any} */ e) { this.#logger.warn(`Error executing action: ${e.message ?? e}`) @@ -877,7 +877,7 @@ function shouldShowInvertForFeedback(options) { /** * @typedef {{ * controlId: string - * deviceid: string | undefined + * surfaceId: string | undefined * location: import('../Resources/Util.js').ControlLocation | undefined * }} RunActionExtras */ diff --git a/lib/Internal/Controls.js b/lib/Internal/Controls.js index 8aef9dc86f..9677dfd9bc 100644 --- a/lib/Internal/Controls.js +++ b/lib/Internal/Controls.js @@ -837,8 +837,8 @@ export default class Controls { const forcePress = !!action.options.force - this.#controlsController.pressControl(theControlId, true, extras.deviceid, forcePress) - this.#controlsController.pressControl(theControlId, false, extras.deviceid, forcePress) + this.#controlsController.pressControl(theControlId, true, extras.surfaceId, forcePress) + this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) return true } else if (action.action == 'button_pressrelease_if_expression') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) @@ -849,8 +849,8 @@ export default class Controls { const pressIt = !!this.#variableController.parseExpression(action.options.expression, 'boolean').value if (pressIt) { - this.#controlsController.pressControl(theControlId, true, extras.deviceid, forcePress) - this.#controlsController.pressControl(theControlId, false, extras.deviceid, forcePress) + this.#controlsController.pressControl(theControlId, true, extras.surfaceId, forcePress) + this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) } return true } else if (action.action == 'button_pressrelease_condition') { @@ -867,33 +867,33 @@ export default class Controls { let pressIt = checkCondition(action.options.op, condition, variable_value) if (pressIt) { - this.#controlsController.pressControl(theControlId, true, extras.deviceid, forcePress) - this.#controlsController.pressControl(theControlId, false, extras.deviceid, forcePress) + this.#controlsController.pressControl(theControlId, true, extras.surfaceId, forcePress) + this.#controlsController.pressControl(theControlId, false, extras.surfaceId, forcePress) } return true } else if (action.action === 'button_press') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) if (!theControlId) return true - this.#controlsController.pressControl(theControlId, true, extras.deviceid, !!action.options.force) + this.#controlsController.pressControl(theControlId, true, extras.surfaceId, !!action.options.force) return true } else if (action.action === 'button_release') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) if (!theControlId) return true - this.#controlsController.pressControl(theControlId, false, extras.deviceid, !!action.options.force) + this.#controlsController.pressControl(theControlId, false, extras.surfaceId, !!action.options.force) return true } else if (action.action === 'button_rotate_left') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) if (!theControlId) return true - this.#controlsController.rotateControl(theControlId, false, extras.deviceid) + this.#controlsController.rotateControl(theControlId, false, extras.surfaceId) return true } else if (action.action === 'button_rotate_right') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) if (!theControlId) return true - this.#controlsController.rotateControl(theControlId, true, extras.deviceid) + this.#controlsController.rotateControl(theControlId, true, extras.surfaceId) return true } else if (action.action === 'bgcolor') { const { theControlId } = this.#fetchLocationAndControlId(action.options, extras.location, true) diff --git a/lib/Internal/Surface.js b/lib/Internal/Surface.js index 682ce98831..9cc52930c3 100644 --- a/lib/Internal/Surface.js +++ b/lib/Internal/Surface.js @@ -146,7 +146,7 @@ export default class Surface { theController = theController.trim() - if (info && theController === 'self') theController = info.deviceid + if (info && theController === 'self') theController = info.surfaceId return theController } @@ -313,9 +313,9 @@ export default class Surface { const thePage = this.#fetchPage(action.options, extras.location, true) if (thePage === undefined) return true - const deviceId = this.#surfaceController.getDeviceIdFromIndex(action.options.controller) - if (deviceId !== undefined) { - this.#changeSurfacePage(deviceId, thePage) + const surfaceId = this.#surfaceController.getDeviceIdFromIndex(action.options.controller) + if (surfaceId !== undefined) { + this.#changeSurfacePage(surfaceId, thePage) } else { this.#logger.warn(`Trying to set controller #${action.options.controller} but it isn't available.`) } @@ -337,11 +337,11 @@ export default class Surface { const theController = this.#fetchControllerId(action.options, extras, true) if (!theController) return true - if (extras.controlId && extras.deviceid == theController) { + if (extras.controlId && extras.surfaceId == theController) { const control = this.#controlsController.getControl(extras.controlId) if (control && control.supportsPushed) { // Make sure the button doesn't show as pressed - control.setPushed(false, extras.deviceid) + control.setPushed(false, extras.surfaceId) } } @@ -365,7 +365,7 @@ export default class Surface { const control = this.#controlsController.getControl(extras.controlId) if (control && control.supportsPushed) { // Make sure the button doesn't show as pressed - control.setPushed(false, extras.deviceid) + control.setPushed(false, extras.surfaceId) } } diff --git a/lib/Service/Api.js b/lib/Service/Api.js index 67b2306219..d9220d86c0 100644 --- a/lib/Service/Api.js +++ b/lib/Service/Api.js @@ -43,29 +43,29 @@ class ServiceApi extends CoreBase { } #setupRoutes() { - this.#router.addPath('page-set :page(\\d+) :deviceId', (match) => { + this.#router.addPath('page-set :page(\\d+) :surfaceId', (match) => { const page = parseInt(match.page) - const deviceId = match.deviceId + const surfaceId = match.surfaceId - this.surfaces.devicePageSet(deviceId, page) + this.surfaces.devicePageSet(surfaceId, page) - return `If ${deviceId} is connected` + return `If ${surfaceId} is connected` }) - this.#router.addPath('page-up :deviceId', (match) => { - const deviceId = match.deviceId + this.#router.addPath('page-up :surfaceId', (match) => { + const surfaceId = match.surfaceId - this.surfaces.devicePageUp(deviceId) + this.surfaces.devicePageUp(surfaceId) - return `If ${deviceId} is connected` + return `If ${surfaceId} is connected` }) - this.#router.addPath('page-down :deviceId', (match) => { - const deviceId = match.deviceId + this.#router.addPath('page-down :surfaceId', (match) => { + const surfaceId = match.surfaceId - this.surfaces.devicePageDown(deviceId) + this.surfaces.devicePageDown(surfaceId) - return `If ${deviceId} is connected` + return `If ${surfaceId} is connected` }) this.#router.addPath('bank-press :page(\\d+) :bank(\\d+)', (match) => { diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index 2e848a5298..b7da4f233f 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -329,11 +329,11 @@ class ServiceEmberPlus extends ServiceBase { * Send the latest bank state to the page/bank indicated * @param {import('../Resources/Util.js').ControlLocation} location - the location of the control * @param {boolean} pushed - the state - * @param {string | undefined} deviceid - checks the deviceid to ensure that Ember+ doesn't loop its own state change back + * @param {string | undefined} surfaceId - checks the surfaceId to ensure that Ember+ doesn't loop its own state change back */ - updateBankState(location, pushed, deviceid) { + updateBankState(location, pushed, surfaceId) { if (!this.server) return - if (deviceid === 'emberplus') return + if (surfaceId === 'emberplus') return const bank = xyToOldBankIndex(location.column, location.row) diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index 67a4c51087..618857ef1b 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -190,9 +190,9 @@ class SurfaceController extends CoreBase { let doLockout = false for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.deviceId, timeout)) { + if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { doLockout = true - this.#surfacesLastInteraction.delete(device.deviceId) + this.#surfacesLastInteraction.delete(device.surfaceId) } } @@ -201,9 +201,9 @@ class SurfaceController extends CoreBase { } } else { for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.deviceId, timeout)) { - this.#surfacesLastInteraction.delete(device.deviceId) - this.setDeviceLocked(device.deviceId, true) + if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { + this.#surfacesLastInteraction.delete(device.surfaceId) + this.setDeviceLocked(device.surfaceId, true) } } } @@ -213,14 +213,14 @@ class SurfaceController extends CoreBase { /** * Check if a surface should be timed out - * @param {string} deviceId + * @param {string} surfaceId * @param {number} timeout * @returns {boolean} */ - #isSurfaceTimedOut(deviceId, timeout) { + #isSurfaceTimedOut(surfaceId, timeout) { if (!this.isPinLockEnabled()) return false - const lastInteraction = this.#surfacesLastInteraction.get(deviceId) || 0 + const lastInteraction = this.#surfacesLastInteraction.get(surfaceId) || 0 return lastInteraction + timeout < Date.now() } @@ -254,7 +254,7 @@ class SurfaceController extends CoreBase { * @returns {void} */ #createSurfaceHandler(surfaceId, integrationType, panel) { - const deviceId = panel.info.deviceId + const panelSurfaceId = panel.info.deviceId let isLocked = false if (this.isPinLockEnabled()) { @@ -262,9 +262,9 @@ class SurfaceController extends CoreBase { if (this.userconfig.getKey('link_lockouts')) { isLocked = this.#surfacesAllLocked } else if (timeout && !isNaN(timeout)) { - isLocked = this.#isSurfaceTimedOut(deviceId, timeout) + isLocked = this.#isSurfaceTimedOut(panelSurfaceId, timeout) } else { - isLocked = !this.#surfacesLastInteraction.has(deviceId) + isLocked = !this.#surfacesLastInteraction.has(panelSurfaceId) } } @@ -277,13 +277,13 @@ class SurfaceController extends CoreBase { const handler = new SurfaceHandler(this.registry, integrationType, panel, isLocked, surfaceConfig) handler.on('interaction', () => { - this.#surfacesLastInteraction.set(deviceId, Date.now()) + this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) }) handler.on('configUpdated', (newConfig) => { - this.setDeviceConfig(handler.deviceId, newConfig) + this.setDeviceConfig(handler.surfaceId, newConfig) }) handler.on('unlocked', () => { - this.#surfacesLastInteraction.set(deviceId, Date.now()) + this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) if (this.userconfig.getKey('link_lockouts')) { this.setAllLocked(false) @@ -293,7 +293,7 @@ class SurfaceController extends CoreBase { this.#surfaceHandlers.set(surfaceId, handler) if (!isLocked) { // If not already locked, keep it unlocked for the full timeout - this.#surfacesLastInteraction.set(deviceId, Date.now()) + this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) } } @@ -403,7 +403,7 @@ class SurfaceController extends CoreBase { */ (id, name) => { for (let instance of this.#surfaceHandlers.values()) { - if (instance.deviceId == id) { + if (instance.surfaceId == id) { instance.setPanelName(name) this.updateDevicesList() } @@ -419,7 +419,7 @@ class SurfaceController extends CoreBase { */ (id) => { for (let instance of this.#surfaceHandlers.values()) { - if (instance.deviceId == id) { + if (instance.surfaceId == id) { return instance.getPanelConfig() } } @@ -436,7 +436,7 @@ class SurfaceController extends CoreBase { */ (id, config) => { for (let instance of this.#surfaceHandlers.values()) { - if (instance.deviceId == id) { + if (instance.surfaceId == id) { instance.setPanelConfig(config) return instance.getPanelConfig() } @@ -484,7 +484,7 @@ class SurfaceController extends CoreBase { */ (id) => { for (let instance of this.#surfaceHandlers.values()) { - if (instance.deviceId == id) { + if (instance.surfaceId == id) { return 'device is active' } } @@ -544,7 +544,7 @@ class SurfaceController extends CoreBase { const instanceMap = new Map() for (const instance of this.#surfaceHandlers.values()) { - instanceMap.set(instance.deviceId, instance) + instanceMap.set(instance.surfaceId, instance) } const surfaceIds = Array.from(new Set([...Object.keys(config), ...instanceMap.keys()])) @@ -901,18 +901,18 @@ class SurfaceController extends CoreBase { /** * Import a surface configuration - * @param {string} deviceId + * @param {string} surfaceId * @param {*} config * @returns {void} */ - importSurface(deviceId, config) { - const device = this.#getSurfaceHandlerForId(deviceId, true) + importSurface(surfaceId, config) { + const device = this.#getSurfaceHandlerForId(surfaceId, true) if (device) { // Device is currently loaded device.setPanelConfig(config) } else { // Device is not loaded - this.setDeviceConfig(deviceId, config) + this.setDeviceConfig(surfaceId, config) } this.updateDevicesList() @@ -972,50 +972,50 @@ class SurfaceController extends CoreBase { /** * Perform page-up for a surface - * @param {string} deviceId + * @param {string} surfaceId * @param {boolean} looseIdMatching * @returns {void} */ - devicePageUp(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + devicePageUp(surfaceId, looseIdMatching = false) { + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { device.doPageUp() } } /** * Perform page-down for a surface - * @param {string} deviceId + * @param {string} surfaceId * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageDown(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + devicePageDown(surfaceId, looseIdMatching = false) { + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { device.doPageDown() } } /** * Set the page number for a surface - * @param {string} deviceId + * @param {string} surfaceId * @param {number} page * @param {boolean=} looseIdMatching * @param {boolean=} defer Defer the drawing to the next tick * @returns {void} */ - devicePageSet(deviceId, page, looseIdMatching = false, defer = false) { - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + devicePageSet(surfaceId, page, looseIdMatching = false, defer = false) { + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { device.setCurrentPage(page, defer) } } /** * Get the page number of a surface - * @param {string} deviceId + * @param {string} surfaceId * @param {boolean=} looseIdMatching * @returns {number | undefined} */ - devicePageGet(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + devicePageGet(surfaceId, looseIdMatching = false) { + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { return device.getCurrentPage() } else { @@ -1057,7 +1057,7 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!locked for (const device of this.#surfaceHandlers.values()) { - this.#surfacesLastInteraction.set(device.deviceId, Date.now()) + this.#surfacesLastInteraction.set(device.surfaceId, Date.now()) device.setLocked(!!locked) } @@ -1065,12 +1065,12 @@ class SurfaceController extends CoreBase { /** * Set all surfaces as locked - * @param {string} deviceId + * @param {string} surfaceId * @param {boolean} locked * @param {boolean} looseIdMatching * @returns {void} */ - setDeviceLocked(deviceId, locked, looseIdMatching = false) { + setDeviceLocked(surfaceId, locked, looseIdMatching = false) { if (!this.isPinLockEnabled()) return if (this.userconfig.getKey('link_lockouts')) { @@ -1080,12 +1080,12 @@ class SurfaceController extends CoreBase { // Track the lock/unlock state, even if the device isn't online if (locked) { - this.#surfacesLastInteraction.delete(deviceId) + this.#surfacesLastInteraction.delete(surfaceId) } else { - this.#surfacesLastInteraction.set(deviceId, Date.now()) + this.#surfacesLastInteraction.set(surfaceId, Date.now()) } - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { device.setLocked(!!locked) } @@ -1094,13 +1094,13 @@ class SurfaceController extends CoreBase { /** * Set the brightness of a surface - * @param {string} deviceId + * @param {string} surfaceId * @param {number} brightness 0-100 * @param {boolean} looseIdMatching * @returns {void} */ - setDeviceBrightness(deviceId, brightness, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) + setDeviceBrightness(surfaceId, brightness, looseIdMatching = false) { + const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) if (device) { device.setBrightness(brightness) } @@ -1118,25 +1118,25 @@ class SurfaceController extends CoreBase { const instances = Array.from(this.#surfaceHandlers.values()) // try and find exact match - let device = instances.find((d) => d.deviceId === surfaceId) + let device = instances.find((d) => d.surfaceId === surfaceId) if (device) return device // only try more variations if the id isnt new format if (!looseIdMatching || surfaceId.includes(':')) return undefined // try the most likely streamdeck prefix - let deviceId2 = `streamdeck:${surfaceId}` - device = instances.find((d) => d.deviceId === deviceId2) + let surfaceId2 = `streamdeck:${surfaceId}` + device = instances.find((d) => d.surfaceId === surfaceId2) if (device) return device // it is unlikely, but it could be a loupedeck - deviceId2 = `loupedeck:${surfaceId}` - device = instances.find((d) => d.deviceId === deviceId2) + surfaceId2 = `loupedeck:${surfaceId}` + device = instances.find((d) => d.surfaceId === surfaceId2) if (device) return device // or maybe a satellite? - deviceId2 = `satellite-${surfaceId}` - return instances.find((d) => d.deviceId === deviceId2) + surfaceId2 = `satellite-${surfaceId}` + return instances.find((d) => d.surfaceId === surfaceId2) } } diff --git a/lib/Surface/Handler.js b/lib/Surface/Handler.js index 218c259492..fe4454c28f 100644 --- a/lib/Surface/Handler.js +++ b/lib/Surface/Handler.js @@ -183,7 +183,7 @@ class SurfaceHandler extends CoreBase { * @param {any | undefined} surfaceConfig */ constructor(registry, integrationType, panel, isLocked, surfaceConfig) { - super(registry, `device(${panel.info.deviceId})`, `Surface/Handler/${panel.info.deviceId}`) + super(registry, `surface(${panel.info.deviceId})`, `Surface/Handler/${panel.info.deviceId}`) this.logger.silly('loading for ' + panel.info.devicePath) this.panel = panel @@ -208,7 +208,7 @@ class SurfaceHandler extends CoreBase { this.#pincodeCodePosition = [3, 4] } - this.#currentPage = 1 // The current page of the device + this.#currentPage = 1 // The current page of the surface // Persist the type in the db for use when it is disconnected this.#surfaceConfig.type = this.panel.info.type || 'Unknown' @@ -269,7 +269,7 @@ class SurfaceHandler extends CoreBase { this.panel.setConfig(config, true) } - this.surfaces.emit('surface_page', this.deviceId, this.#currentPage) + this.surfaces.emit('surface_page', this.surfaceId, this.#currentPage) this.#drawPage() }) @@ -282,11 +282,11 @@ class SurfaceHandler extends CoreBase { } } - get deviceId() { + get surfaceId() { return this.panel.info.deviceId } - #deviceIncreasePage() { + #surfaceIncreasePage() { this.#currentPage++ if (this.#currentPage >= 100) { this.#currentPage = 1 @@ -298,7 +298,7 @@ class SurfaceHandler extends CoreBase { this.#storeNewDevicePage(this.#currentPage) } - #deviceDecreasePage() { + #surfaceDecreasePage() { this.#currentPage-- if (this.#currentPage >= 100) { this.#currentPage = 1 @@ -388,7 +388,7 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ #onButtonDrawn = (location, render) => { - // If device is locked ignore updates. pincode updates are handled separately + // If surface is locked ignore updates. pincode updates are handled separately if (this.#isSurfaceLocked) return if (this.#xkeysPageCount > 0) { @@ -485,7 +485,7 @@ class SurfaceHandler extends CoreBase { row: y2 + yOffset, }) if (controlId) { - this.controls.pressControl(controlId, pressed, this.deviceId) + this.controls.pressControl(controlId, pressed, this.surfaceId) } this.logger.debug(`Button ${thisPage}/${coordinate} ${pressed ? 'pressed' : 'released'}`) } else { @@ -547,7 +547,7 @@ class SurfaceHandler extends CoreBase { row: y2 + yOffset, }) if (controlId) { - this.controls.rotateControl(controlId, direction, this.deviceId) + this.controls.rotateControl(controlId, direction, this.surfaceId) } this.logger.debug(`Rotary ${thisPage}/${x2 + xOffset}/${y2 + yOffset} rotated ${direction ? 'right' : 'left'}`) } else { @@ -610,9 +610,9 @@ class SurfaceHandler extends CoreBase { doPageDown() { if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#deviceIncreasePage() + this.#surfaceIncreasePage() } else { - this.#deviceDecreasePage() + this.#surfaceDecreasePage() } } @@ -639,9 +639,9 @@ class SurfaceHandler extends CoreBase { doPageUp() { if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#deviceDecreasePage() + this.#surfaceDecreasePage() } else { - this.#deviceIncreasePage() + this.#surfaceIncreasePage() } } @@ -717,7 +717,7 @@ class SurfaceHandler extends CoreBase { this.#surfaceConfig.page = this.#currentPage = newpage this.#saveConfig() - this.surfaces.emit('surface_page', this.deviceId, newpage) + this.surfaces.emit('surface_page', this.surfaceId, newpage) if (defer) { setImmediate(() => { @@ -742,13 +742,13 @@ class SurfaceHandler extends CoreBase { this.panel.quit() } catch (e) {} - // Fetch the deviceId before destroying the panel - const deviceId = this.deviceId + // Fetch the surfaceId before destroying the panel + const surfaceId = this.surfaceId // delete this.panel.device // delete this.panel - if (purge && deviceId) { + if (purge && surfaceId) { this.#surfaceConfig = {} this.emit('configUpdated', undefined) diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx index 2bec7a3af9..ff0d686736 100644 --- a/webui/src/Surfaces/EditModal.jsx +++ b/webui/src/Surfaces/EditModal.jsx @@ -18,52 +18,52 @@ import { nanoid } from 'nanoid' export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { const socket = useContext(SocketContext) - const [deviceInfo, setDeviceInfo] = useState(null) + const [surfaceInfo, setSurfaceInfo] = useState(null) const [show, setShow] = useState(false) - const [deviceConfig, setDeviceConfig] = useState(null) - const [deviceConfigError, setDeviceConfigError] = useState(null) + const [surfaceConfig, setSurfaceConfig] = useState(null) + const [surfaceConfigError, setSurfaceConfigError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doClose = useCallback(() => setShow(false), []) const onClosed = useCallback(() => { - setDeviceInfo(null) - setDeviceConfig(null) - setDeviceConfigError(null) + setSurfaceInfo(null) + setSurfaceConfig(null) + setSurfaceConfigError(null) }, []) const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) useEffect(() => { - setDeviceConfigError(null) - setDeviceConfig(null) + setSurfaceConfigError(null) + setSurfaceConfig(null) - if (deviceInfo?.id) { - socketEmitPromise(socket, 'surfaces:config-get', [deviceInfo.id]) + if (surfaceInfo?.id) { + socketEmitPromise(socket, 'surfaces:config-get', [surfaceInfo.id]) .then((config) => { console.log(config) - setDeviceConfig(config) + setSurfaceConfig(config) }) .catch((err) => { - console.error('Failed to load device config') - setDeviceConfigError(`Failed to load device config`) + console.error('Failed to load surface config') + setSurfaceConfigError(`Failed to load surface config`) }) } - }, [socket, deviceInfo?.id, reloadToken]) + }, [socket, surfaceInfo?.id, reloadToken]) useImperativeHandle( ref, () => ({ - show(device) { - setDeviceInfo(device) + show(surface) { + setSurfaceInfo(surface) setShow(true) }, - ensureIdIsValid(deviceIds) { - setDeviceInfo((oldDevice) => { - if (oldDevice && deviceIds.indexOf(oldDevice.id) === -1) { + ensureIdIsValid(surfaceIds) { + setSurfaceInfo((oldSurface) => { + if (oldSurface && surfaceIds.indexOf(oldSurface.id) === -1) { setShow(false) } - return oldDevice + return oldSurface }) }, }), @@ -73,19 +73,19 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref const updateConfig = useCallback( (key, value) => { console.log('update', key, value) - if (deviceInfo?.id) { - setDeviceConfig((oldConfig) => { + if (surfaceInfo?.id) { + setSurfaceConfig((oldConfig) => { const newConfig = { ...oldConfig, [key]: value, } - socketEmitPromise(socket, 'surfaces:config-set', [deviceInfo.id, newConfig]) + socketEmitPromise(socket, 'surfaces:config-set', [surfaceInfo.id, newConfig]) .then((newConfig) => { if (typeof newConfig === 'string') { console.log('Config update failed', newConfig) } else { - setDeviceConfig(newConfig) + setSurfaceConfig(newConfig) } }) .catch((e) => { @@ -95,24 +95,24 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref }) } }, - [socket, deviceInfo?.id] + [socket, surfaceInfo?.id] ) return ( -
Settings for {deviceInfo?.type}
+
Settings for {surfaceInfo?.type}
- - {deviceConfig && deviceInfo && ( + + {surfaceConfig && surfaceInfo && ( Use Last Page At Startup updateConfig('use_last_page', !!e.currentTarget.checked)} /> @@ -120,18 +120,18 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref Startup Page updateConfig('page', parseInt(e.currentTarget.value))} /> - {deviceConfig.page} + {surfaceConfig.page} - {deviceInfo.configFields?.includes('emulator_size') && ( + {surfaceInfo.configFields?.includes('emulator_size') && ( <> Row count @@ -140,7 +140,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref type="number" min={1} step={1} - value={deviceConfig.emulator_rows} + value={surfaceConfig.emulator_rows} onChange={(e) => updateConfig('emulator_rows', parseInt(e.currentTarget.value))} /> @@ -151,7 +151,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref type="number" min={1} step={1} - value={deviceConfig.emulator_columns} + value={surfaceConfig.emulator_columns} onChange={(e) => updateConfig('emulator_columns', parseInt(e.currentTarget.value))} /> @@ -164,7 +164,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref name="page" type="number" step={1} - value={deviceConfig.xOffset} + value={surfaceConfig.xOffset} onChange={(e) => updateConfig('xOffset', parseInt(e.currentTarget.value))} /> @@ -174,12 +174,12 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref name="page" type="number" step={1} - value={deviceConfig.yOffset} + value={surfaceConfig.yOffset} onChange={(e) => updateConfig('yOffset', parseInt(e.currentTarget.value))} /> - {deviceInfo.configFields?.includes('brightness') && ( + {surfaceInfo.configFields?.includes('brightness') && ( Brightness updateConfig('brightness', parseInt(e.currentTarget.value))} /> )} - {deviceInfo.configFields?.includes('illuminate_pressed') && ( + {surfaceInfo.configFields?.includes('illuminate_pressed') && ( Illuminate pressed buttons updateConfig('illuminate_pressed', !!e.currentTarget.checked)} /> @@ -210,7 +210,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref Button rotation { const valueNumber = parseInt(e.currentTarget.value) updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) @@ -221,7 +221,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref - {deviceInfo.configFields?.includes('legacy_rotation') && ( + {surfaceInfo.configFields?.includes('legacy_rotation') && ( <> @@ -230,31 +230,31 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref )} - {deviceInfo.configFields?.includes('emulator_control_enable') && ( + {surfaceInfo.configFields?.includes('emulator_control_enable') && ( Enable support for Logitech R400/Mastercue/DSan updateConfig('emulator_control_enable', !!e.currentTarget.checked)} /> )} - {deviceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( + {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( Prompt to enter fullscreen updateConfig('emulator_prompt_fullscreen', !!e.currentTarget.checked)} /> )} - {deviceInfo.configFields?.includes('videohub_page_count') && ( + {surfaceInfo.configFields?.includes('videohub_page_count') && ( Page Count updateConfig('videohub_page_count', parseInt(e.currentTarget.value))} /> @@ -273,7 +273,7 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref updateConfig('never_lock', !!e.currentTarget.checked)} /> diff --git a/webui/src/Surfaces/index.jsx b/webui/src/Surfaces/index.jsx index 22684b8a08..0fb9e879e0 100644 --- a/webui/src/Surfaces/index.jsx +++ b/webui/src/Surfaces/index.jsx @@ -10,12 +10,12 @@ import { SurfaceEditModal } from './EditModal' export const SurfacesPage = memo(function SurfacesPage() { const socket = useContext(SocketContext) - const devices = useContext(SurfacesContext) + const surfaces = useContext(SurfacesContext) const confirmRef = useRef(null) - const devicesList = useMemo(() => { - const ary = Object.values(devices.available) + const surfacesList = useMemo(() => { + const ary = Object.values(surfaces.available) ary.sort((a, b) => { if (a.index !== b.index) { @@ -27,9 +27,9 @@ export const SurfacesPage = memo(function SurfacesPage() { }) return ary - }, [devices.available]) - const offlineDevicesList = useMemo(() => { - const ary = Object.values(devices.offline) + }, [surfaces.available]) + const offlineSurfacesList = useMemo(() => { + const ary = Object.values(surfaces.offline) ary.sort((a, b) => { if (a.index !== b.index) { @@ -41,7 +41,7 @@ export const SurfacesPage = memo(function SurfacesPage() { }) return ary - }, [devices.offline]) + }, [surfaces.offline]) const editModalRef = useRef() const confirmModalRef = useRef(null) @@ -50,11 +50,11 @@ export const SurfacesPage = memo(function SurfacesPage() { const [scanError, setScanError] = useState(null) useEffect(() => { - // If device disappears, hide the edit modal + // If surface disappears, hide the edit modal if (editModalRef.current) { - editModalRef.current.ensureIdIsValid(Object.keys(devices)) + editModalRef.current.ensureIdIsValid(Object.keys(surfaces)) } - }, [devices]) + }, [surfaces]) const refreshUSB = useCallback(() => { setScanning(true) @@ -79,9 +79,9 @@ export const SurfacesPage = memo(function SurfacesPage() { }, [socket]) const deleteEmulator = useCallback( - (deviceId) => { + (surfaceId) => { confirmRef?.current?.show('Remove Emulator', 'Are you sure?', 'Remove', () => { - socketEmitPromise(socket, 'surfaces:emulator-remove', [deviceId]).catch((err) => { + socketEmitPromise(socket, 'surfaces:emulator-remove', [surfaceId]).catch((err) => { console.error('Emulator remove failed', err) }) }) @@ -89,18 +89,18 @@ export const SurfacesPage = memo(function SurfacesPage() { [socket] ) - const configureDevice = useCallback((device) => { - editModalRef.current.show(device) + const configureSurface = useCallback((surface) => { + editModalRef.current.show(surface) }, []) - const forgetDevice = useCallback( - (deviceId) => { + const forgetSurface = useCallback( + (surfaceId) => { confirmModalRef.current.show( 'Forget Surface', 'Are you sure you want to forget this surface? Any settings will be lost', 'Forget', () => { - socketEmitPromise(socket, 'surfaces:forget', [deviceId]).catch((err) => { + socketEmitPromise(socket, 'surfaces:forget', [surfaceId]).catch((err) => { console.error('fotget failed', err) }) } @@ -110,8 +110,8 @@ export const SurfacesPage = memo(function SurfacesPage() { ) const updateName = useCallback( - (deviceId, name) => { - socketEmitPromise(socket, 'surfaces:set-name', [deviceId, name]).catch((err) => { + (surfaceId, name) => { + socketEmitPromise(socket, 'surfaces:set-name', [surfaceId, name]).catch((err) => { console.error('Update name failed', err) }) }, @@ -143,7 +143,7 @@ export const SurfacesPage = memo(function SurfacesPage() { - {scanning ? ' Checking for new devices...' : ' Rescan USB'} + {scanning ? ' Checking for new surfaces...' : ' Rescan USB'} Add Emulator @@ -169,17 +169,17 @@ export const SurfacesPage = memo(function SurfacesPage() { - {devicesList.map((dev) => ( - ( + ))} - {devicesList.length === 0 && ( + {surfacesList.length === 0 && ( No control surfaces have been detected @@ -199,11 +199,16 @@ export const SurfacesPage = memo(function SurfacesPage() { - {offlineDevicesList.map((dev) => ( - + {offlineSurfacesList.map((surface) => ( + ))} - {offlineDevicesList.length === 0 && ( + {offlineSurfacesList.length === 0 && ( No items @@ -214,29 +219,29 @@ export const SurfacesPage = memo(function SurfacesPage() { ) }) -function AvailableDeviceRow({ device, updateName, configureDevice, deleteEmulator }) { - const updateName2 = useCallback((val) => updateName(device.id, val), [updateName, device.id]) - const configureDevice2 = useCallback(() => configureDevice(device), [configureDevice, device]) - const deleteEmulator2 = useCallback(() => deleteEmulator(device.id), [deleteEmulator, device.id]) +function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmulator }) { + const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) + const configureSurface2 = useCallback(() => configureSurface(surface), [configureSurface, surface]) + const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) return ( - #{device.index} - {device.id} + #{surface.index} + {surface.id} - + - {device.type} - {device.location} + {surface.type} + {surface.location} - + Settings - {device.integrationType === 'emulator' && ( + {surface.integrationType === 'emulator' && ( <> - + @@ -250,19 +255,19 @@ function AvailableDeviceRow({ device, updateName, configureDevice, deleteEmulato ) } -function OfflineDeviceRow({ device, updateName, forgetDevice }) { - const updateName2 = useCallback((val) => updateName(device.id, val), [updateName, device.id]) - const forgetDevice2 = useCallback(() => forgetDevice(device.id), [forgetDevice, device.id]) +function OfflineSuraceRow({ surface, updateName, forgetSurface }) { + const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) + const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) return ( - {device.id} + {surface.id} - + - {device.type} + {surface.type} - + Forget From 27dbbea8a2d5abc863b89918be349fcf6270f72a Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 13:25:38 +0000 Subject: [PATCH 18/53] chore: rename bulk of instance to connection --- lib/Controls/ActionRecorder.js | 40 +++---- lib/Controls/ControlBase.js | 6 +- lib/Controls/ControlTypes/Button/Base.js | 10 +- lib/Controls/ControlTypes/Button/Normal.js | 12 +- lib/Controls/ControlTypes/PageDown.js | 6 +- lib/Controls/ControlTypes/PageNumber.js | 6 +- lib/Controls/ControlTypes/PageUp.js | 6 +- lib/Controls/ControlTypes/Triggers/Trigger.js | 14 +-- lib/Data/ImportExport.js | 99 ++++++++-------- lib/Instance/Controller.js | 40 +++---- lib/Instance/Definitions.js | 82 +++++++------- lib/Instance/Modules.js | 2 +- lib/Instance/Status.js | 4 +- lib/Instance/Variable.js | 2 +- lib/Internal/ActionRecorder.js | 6 +- lib/Internal/Controls.js | 8 +- lib/Internal/CustomVariables.js | 4 +- lib/Registry.js | 2 +- lib/Util/Visitors/ReferencesCollector.js | 22 ++-- lib/Util/Visitors/ReferencesUpdater.js | 28 ++--- webui/src/App.jsx | 4 +- webui/src/Buttons/ActionRecorder.jsx | 6 +- webui/src/Buttons/Presets.jsx | 10 +- webui/src/Buttons/Variables.jsx | 24 ++-- webui/src/Components/TextInputField.jsx | 4 +- webui/src/ConnectionDebug.jsx | 4 +- .../AddConnection.jsx} | 28 ++--- .../ConnectionEditPanel.jsx} | 84 ++++++++------ .../ConnectionList.jsx} | 107 ++++++++++-------- .../ConnectionVariablesModal.jsx} | 12 +- .../{Instances => Connections}/HelpModal.jsx | 0 .../src/{Instances => Connections}/index.jsx | 60 +++++----- webui/src/ContextData.jsx | 14 +-- webui/src/Controls/ActionSetEditor.jsx | 24 ++-- webui/src/Controls/AddModal.jsx | 44 +++---- webui/src/Controls/FeedbackEditor.jsx | 24 ++-- webui/src/Controls/InternalInstanceFields.jsx | 8 +- webui/src/ImportExport/Import/Page.jsx | 6 +- webui/src/ImportExport/index.jsx | 8 +- webui/src/Triggers/index.jsx | 6 +- webui/src/scss/_instances.scss | 20 ++-- webui/src/util.jsx | 2 +- 42 files changed, 462 insertions(+), 436 deletions(-) rename webui/src/{Instances/AddInstance.jsx => Connections/AddConnection.jsx} (84%) rename webui/src/{Instances/InstanceEditPanel.jsx => Connections/ConnectionEditPanel.jsx} (78%) rename webui/src/{Instances/InstanceList.jsx => Connections/ConnectionList.jsx} (76%) rename webui/src/{Instances/InstanceVariablesModal.jsx => Connections/ConnectionVariablesModal.jsx} (70%) rename webui/src/{Instances => Connections}/HelpModal.jsx (100%) rename webui/src/{Instances => Connections}/index.jsx (60%) diff --git a/lib/Controls/ActionRecorder.js b/lib/Controls/ActionRecorder.js index fde4b856b1..c291d56ec2 100644 --- a/lib/Controls/ActionRecorder.js +++ b/lib/Controls/ActionRecorder.js @@ -17,7 +17,7 @@ function SessionRoom(id) { /** * @typedef {{ * id: string - * instanceIds: string[] + * connectionIds: string[] * isRunning: boolean * actionDelay: number * actions: RecordActionTmp[] @@ -26,7 +26,7 @@ function SessionRoom(id) { /** * @typedef {{ - * instanceIds: string[] + * connectionIds: string[] * }} RecordSessionListInfo */ @@ -115,7 +115,7 @@ export default class ActionRecorder extends EventEmitter { // create the 'default' session this.#currentSession = { id: nanoid(), - instanceIds: [], + connectionIds: [], isRunning: false, actionDelay: 0, actions: [], @@ -191,11 +191,11 @@ export default class ActionRecorder extends EventEmitter { ) client.onPromise( 'action-recorder:session:set-instances', - (/** @type {string} */ sessionId, /** @type {string[]} */ instanceIds) => { + (/** @type {string} */ sessionId, /** @type {string[]} */ connectionIds) => { if (!this.#currentSession || this.#currentSession.id !== sessionId) throw new Error(`Invalid session: ${sessionId}`) - this.setSelectedInstanceIds(instanceIds) + this.setSelectedInstanceIds(connectionIds) return true } @@ -348,7 +348,7 @@ export default class ActionRecorder extends EventEmitter { if (this.#currentSession) { newSessionListJson[this.#currentSession.id] = { - instanceIds: cloneDeep(this.#currentSession.instanceIds), + connectionIds: cloneDeep(this.#currentSession.connectionIds), } } @@ -368,9 +368,9 @@ export default class ActionRecorder extends EventEmitter { * Destroy the recorder session, and create a fresh one * Note: this discards any actions that havent yet been added to a control * @access public - * @param {boolean} [preserveInstances] + * @param {boolean} [preserveConnections] */ - destroySession(preserveInstances) { + destroySession(preserveConnections) { const oldSession = this.#currentSession this.#currentSession.isRunning = false @@ -379,14 +379,14 @@ export default class ActionRecorder extends EventEmitter { const newId = nanoid() this.#currentSession = { id: newId, - instanceIds: [], + connectionIds: [], isRunning: false, actionDelay: 0, actions: [], } - if (preserveInstances) { - this.#currentSession.instanceIds.push(...oldSession.instanceIds) + if (preserveConnections) { + this.#currentSession.connectionIds.push(...oldSession.connectionIds) } this.commitChanges([oldSession.id, newId]) @@ -414,9 +414,9 @@ export default class ActionRecorder extends EventEmitter { if (!running) { if (this.#currentSession) { // Remove the instance which has stopped - const newIds = this.#currentSession.instanceIds.filter((id) => id !== instanceId) + const newIds = this.#currentSession.connectionIds.filter((id) => id !== instanceId) - if (newIds.length !== this.#currentSession.instanceIds.length) { + if (newIds.length !== this.#currentSession.connectionIds.length) { this.commitChanges([this.#currentSession.id]) } } @@ -437,7 +437,7 @@ export default class ActionRecorder extends EventEmitter { if (this.#currentSession) { const session = this.#currentSession - if (session.instanceIds.includes(instanceId)) { + if (session.connectionIds.includes(instanceId)) { /** @type {RecordActionTmp} */ const newAction = { id: nanoid(), @@ -511,14 +511,14 @@ export default class ActionRecorder extends EventEmitter { /** * Set the current instances being recorded from - * @param {Array} instanceIds0 + * @param {Array} connectionIds0 */ - setSelectedInstanceIds(instanceIds0) { - if (!Array.isArray(instanceIds0)) throw new Error('Expected array of instance ids') + setSelectedInstanceIds(connectionIds0) { + if (!Array.isArray(connectionIds0)) throw new Error('Expected array of instance ids') const allValidIds = new Set(this.#registry.instance.getAllInstanceIds()) - const instanceIds = instanceIds0.filter((id) => allValidIds.has(id)) + const connectionIds = connectionIds0.filter((id) => allValidIds.has(id)) - this.#currentSession.instanceIds = instanceIds + this.#currentSession.connectionIds = connectionIds this.#syncRecording() this.commitChanges([this.#currentSession.id]) @@ -533,7 +533,7 @@ export default class ActionRecorder extends EventEmitter { const targetRecordingInstanceIds = new Set() if (this.#currentSession && this.#currentSession.isRunning) { - for (const id of this.#currentSession.instanceIds) { + for (const id of this.#currentSession.connectionIds) { targetRecordingInstanceIds.add(id) } } diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index 4bc93b093e..d24f37156e 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -123,12 +123,12 @@ export default class ControlBase extends CoreBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} _foundInstanceIds - instance ids being referenced - * @param {Set} _foundInstanceLabels - instance labels being referenced + * @param {Set} _foundConnectionIds - instance ids being referenced + * @param {Set} _foundConnectionLabels - instance labels being referenced * @access public * @abstract */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { throw new Error('must be implemented by subclass!') } diff --git a/lib/Controls/ControlTypes/Button/Base.js b/lib/Controls/ControlTypes/Button/Base.js index 6abb58a96a..6e75ada5f1 100644 --- a/lib/Controls/ControlTypes/Button/Base.js +++ b/lib/Controls/ControlTypes/Button/Base.js @@ -146,12 +146,12 @@ export default class ButtonControlBase extends ControlBase { */ checkButtonStatus = (redraw = true) => { // Find all the instances referenced by the bank - const instance_ids = new Set() + const connectionIds = new Set() for (const step of Object.values(this.steps)) { for (const actions of Object.values(step.action_sets)) { if (!actions) continue for (const action of actions) { - instance_ids.add(action.instance) + connectionIds.add(action.instance) } } } @@ -159,8 +159,8 @@ export default class ButtonControlBase extends ControlBase { // Figure out the combined status /** @type {'good' | 'warning' | 'error'} */ let status = 'good' - for (const instance_id of instance_ids) { - const instance_status = this.instance.getInstanceStatus(instance_id) + for (const connectionId of connectionIds) { + const instance_status = this.instance.getInstanceStatus(connectionId) if (instance_status) { // TODO - can this be made simpler switch (instance_status.category) { @@ -391,7 +391,7 @@ export default class ButtonControlBase extends ControlBase { // Fix up references const changed = this.registry.data.importExport.fixupControlReferences( - { instanceLabels: { [labelFrom]: labelTo } }, + { connectionLabels: { [labelFrom]: labelTo } }, this.feedbacks.baseStyle, allActions, allFeedbacks, diff --git a/lib/Controls/ControlTypes/Button/Normal.js b/lib/Controls/ControlTypes/Button/Normal.js index 8ea24b0eb9..cd61f3bb67 100644 --- a/lib/Controls/ControlTypes/Button/Normal.js +++ b/lib/Controls/ControlTypes/Button/Normal.js @@ -588,11 +588,11 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} foundInstanceIds - instance ids being referenced - * @param {Set} foundInstanceLabels - instance labels being referenced + * @param {Set} foundConnectionIds - instance ids being referenced + * @param {Set} foundConnectionLabels - instance labels being referenced * @access public */ - collectReferencedInstances(foundInstanceIds, foundInstanceLabels) { + collectReferencedConnections(foundConnectionIds, foundConnectionLabels) { const allFeedbacks = this.feedbacks.feedbacks const allActions = [] @@ -601,13 +601,13 @@ export default class ControlButtonNormal extends ButtonControlBase { } for (const feedback of allFeedbacks) { - foundInstanceIds.add(feedback.instance_id) + foundConnectionIds.add(feedback.instance_id) } for (const action of allActions) { - foundInstanceIds.add(action.instance) + foundConnectionIds.add(action.instance) } - const visitor = new VisitorReferencesCollector(foundInstanceIds, foundInstanceLabels) + const visitor = new VisitorReferencesCollector(foundConnectionIds, foundConnectionLabels) this.registry.data.importExport.visitControlReferences( visitor, diff --git a/lib/Controls/ControlTypes/PageDown.js b/lib/Controls/ControlTypes/PageDown.js index 6b49de0ff9..c21cbf1aa0 100644 --- a/lib/Controls/ControlTypes/PageDown.js +++ b/lib/Controls/ControlTypes/PageDown.js @@ -122,11 +122,11 @@ export default class ControlButtonPageDown extends ControlBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} _foundInstanceIds - instance ids being referenced - * @param {Set} _foundInstanceLabels - instance labels being referenced + * @param {Set} _foundConnectionIds - instance ids being referenced + * @param {Set} _foundConnectionLabels - instance labels being referenced * @access public */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } diff --git a/lib/Controls/ControlTypes/PageNumber.js b/lib/Controls/ControlTypes/PageNumber.js index a6b23c2a98..62351dcc65 100644 --- a/lib/Controls/ControlTypes/PageNumber.js +++ b/lib/Controls/ControlTypes/PageNumber.js @@ -123,11 +123,11 @@ export default class ControlButtonPageNumber extends ControlBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} _foundInstanceIds - instance ids being referenced - * @param {Set} _foundInstanceLabels - instance labels being referenced + * @param {Set} _foundConnectionIds - instance ids being referenced + * @param {Set} _foundConnectionLabels - instance labels being referenced * @access public */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } diff --git a/lib/Controls/ControlTypes/PageUp.js b/lib/Controls/ControlTypes/PageUp.js index e903a0160d..29c79535a0 100644 --- a/lib/Controls/ControlTypes/PageUp.js +++ b/lib/Controls/ControlTypes/PageUp.js @@ -122,11 +122,11 @@ export default class ControlButtonPageUp extends ControlBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} _foundInstanceIds - instance ids being referenced - * @param {Set} _foundInstanceLabels - instance labels being referenced + * @param {Set} _foundConnectionIds - instance ids being referenced + * @param {Set} _foundConnectionLabels - instance labels being referenced * @access public */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } diff --git a/lib/Controls/ControlTypes/Triggers/Trigger.js b/lib/Controls/ControlTypes/Triggers/Trigger.js index 55db2bf000..39ff8306f5 100644 --- a/lib/Controls/ControlTypes/Triggers/Trigger.js +++ b/lib/Controls/ControlTypes/Triggers/Trigger.js @@ -454,22 +454,22 @@ export default class ControlTrigger extends ControlBase { /** * Collect the instance ids and labels referenced by this control - * @param {Set} foundInstanceIds - instance ids being referenced - * @param {Set} foundInstanceLabels - instance labels being referenced + * @param {Set} foundConnectionIds - instance ids being referenced + * @param {Set} foundConnectionLabels - instance labels being referenced * @access public */ - collectReferencedInstances(foundInstanceIds, foundInstanceLabels) { + collectReferencedConnections(foundConnectionIds, foundConnectionLabels) { const allFeedbacks = this.feedbacks.feedbacks const allActions = this.actions.getAllActions() for (const feedback of allFeedbacks) { - foundInstanceIds.add(feedback.instance_id) + foundConnectionIds.add(feedback.instance_id) } for (const action of allActions) { - foundInstanceIds.add(action.instance) + foundConnectionIds.add(action.instance) } - const visitor = new VisitorReferencesCollector(foundInstanceIds, foundInstanceLabels) + const visitor = new VisitorReferencesCollector(foundConnectionIds, foundConnectionLabels) this.registry.data.importExport.visitControlReferences(visitor, undefined, allActions, allFeedbacks, this.events) } @@ -602,7 +602,7 @@ export default class ControlTrigger extends ControlBase { // Fix up references const changed = this.registry.data.importExport.fixupControlReferences( - { instanceLabels: { [labelFrom]: labelTo } }, + { connectionLabels: { [labelFrom]: labelTo } }, undefined, allActions, allFeedbacks, diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 49608fee92..2f223cccef 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -150,29 +150,29 @@ class DataImportExport extends CoreBase { /** * - * @param {Set} referencedInstanceIds - * @param {Set} referencedInstanceLabels + * @param {Set} referencedConnectionIds + * @param {Set} referencedConnectionLabels * @param {boolean} minimalExport * @returns {import('./Model/ExportModel.js').ExportInstancesv4} */ const generate_export_for_referenced_instances = ( - referencedInstanceIds, - referencedInstanceLabels, + referencedConnectionIds, + referencedConnectionLabels, minimalExport = false ) => { /** @type {import('./Model/ExportModel.js').ExportInstancesv4} */ const instancesExport = {} - referencedInstanceIds.delete('internal') // Ignore the internal module - for (const instance_id of referencedInstanceIds) { - instancesExport[instance_id] = this.instance.exportInstance(instance_id, minimalExport) || {} + referencedConnectionIds.delete('internal') // Ignore the internal module + for (const connectionId of referencedConnectionIds) { + instancesExport[connectionId] = this.instance.exportInstance(connectionId, minimalExport) || {} } - referencedInstanceLabels.delete('internal') // Ignore the internal module - for (const label of referencedInstanceLabels) { - const instance_id = this.instance.getIdForLabel(label) - if (instance_id) { - instancesExport[instance_id] = this.instance.exportInstance(instance_id, minimalExport) || {} + referencedConnectionLabels.delete('internal') // Ignore the internal module + for (const label of referencedConnectionLabels) { + const connectionId = this.instance.getIdForLabel(label) + if (connectionId) { + instancesExport[connectionId] = this.instance.exportInstance(connectionId, minimalExport) || {} } } @@ -187,18 +187,21 @@ class DataImportExport extends CoreBase { const generate_export_for_triggers = (triggerControls) => { /** @type {Record} */ const triggersExport = {} - const referencedInstanceIds = new Set() - const referencedInstanceLabels = new Set() + const referencedConnectionIds = new Set() + const referencedConnectionLabels = new Set() for (const control of triggerControls) { const parsedId = ParseControlId(control.controlId) if (parsedId?.type === 'trigger') { triggersExport[parsedId.trigger] = control.toJSON(false) - control.collectReferencedInstances(referencedInstanceIds, referencedInstanceLabels) + control.collectReferencedConnections(referencedConnectionIds, referencedConnectionLabels) } } - const instancesExport = generate_export_for_referenced_instances(referencedInstanceIds, referencedInstanceLabels) + const instancesExport = generate_export_for_referenced_instances( + referencedConnectionIds, + referencedConnectionLabels + ) return { type: 'trigger_list', @@ -242,14 +245,14 @@ class DataImportExport extends CoreBase { const pageInfo = this.page.getPage(page, true) if (!pageInfo) throw new Error(`Page "${page}" not found!`) - const referencedInstanceIds = new Set() - const referencedInstanceLabels = new Set() + const referencedConnectionIds = new Set() + const referencedConnectionLabels = new Set() - const pageExport = generatePageExportInfo(pageInfo, referencedInstanceIds, referencedInstanceLabels) + const pageExport = generatePageExportInfo(pageInfo, referencedConnectionIds, referencedConnectionLabels) const instancesExport = generate_export_for_referenced_instances( - referencedInstanceIds, - referencedInstanceLabels + referencedConnectionIds, + referencedConnectionLabels ) // Export file protocol version @@ -270,11 +273,11 @@ class DataImportExport extends CoreBase { /** * @param {Readonly} pageInfo - * @param {Set} referencedInstanceIds - * @param {Set} referencedInstanceLabels + * @param {Set} referencedConnectionIds + * @param {Set} referencedConnectionLabels * @returns {import('./Model/ExportModel.js').ExportPageContentv4} */ - const generatePageExportInfo = (pageInfo, referencedInstanceIds, referencedInstanceLabels) => { + const generatePageExportInfo = (pageInfo, referencedConnectionIds, referencedConnectionLabels) => { /** @type {import('./Model/ExportModel.js').ExportPageContentv4} */ const pageExport = { name: pageInfo.name, @@ -289,7 +292,7 @@ class DataImportExport extends CoreBase { if (!pageExport.controls[Number(row)]) pageExport.controls[Number(row)] = {} pageExport.controls[Number(row)][Number(column)] = control.toJSON(false) - control.collectReferencedInstances(referencedInstanceIds, referencedInstanceLabels) + control.collectReferencedConnections(referencedConnectionIds, referencedConnectionLabels) } } } @@ -312,8 +315,8 @@ class DataImportExport extends CoreBase { const rawControls = this.controls.getAllControls() - const referencedInstanceIds = new Set() - const referencedInstanceLabels = new Set() + const referencedConnectionIds = new Set() + const referencedConnectionLabels = new Set() if (!config || !isFalsey(config.buttons)) { exp.pages = {} @@ -322,8 +325,8 @@ class DataImportExport extends CoreBase { for (const [pageNumber, rawPageInfo] of Object.entries(pageInfos)) { exp.pages[Number(pageNumber)] = generatePageExportInfo( rawPageInfo, - referencedInstanceIds, - referencedInstanceLabels + referencedConnectionIds, + referencedConnectionLabels ) } } @@ -337,7 +340,7 @@ class DataImportExport extends CoreBase { if (parsedId?.type === 'trigger') { triggersExport[parsedId.trigger] = control.toJSON(false) - control.collectReferencedInstances(referencedInstanceIds, referencedInstanceLabels) + control.collectReferencedConnections(referencedConnectionIds, referencedConnectionLabels) } } } @@ -351,7 +354,11 @@ class DataImportExport extends CoreBase { if (!config || !isFalsey(config.connections)) { exp.instances = this.instance.exportAll(false) } else { - exp.instances = generate_export_for_referenced_instances(referencedInstanceIds, referencedInstanceLabels, true) + exp.instances = generate_export_for_referenced_instances( + referencedConnectionIds, + referencedConnectionLabels, + true + ) } if (!config || !isFalsey(config.surfaces)) { @@ -1091,15 +1098,15 @@ class DataImportExport extends CoreBase { // Future: this does not feel durable /** @type {Record} */ - const instanceLabelRemap = {} + const connectionLabelRemap = {} /** @type {Record} */ - const instanceIdRemap = {} + const connectionIdRemap = {} for (const [oldId, info] of Object.entries(instanceIdMap)) { if (info.oldLabel && info.label !== info.oldLabel) { - instanceLabelRemap[info.oldLabel] = info.label + connectionLabelRemap[info.oldLabel] = info.label } if (info.id && info.id !== oldId) { - instanceIdRemap[oldId] = info.id + connectionIdRemap[oldId] = info.id } } @@ -1151,8 +1158,8 @@ class DataImportExport extends CoreBase { this.fixupControlReferences( { - instanceLabels: instanceLabelRemap, - instanceIds: instanceIdRemap, + connectionLabels: connectionLabelRemap, + connectionIds: connectionIdRemap, }, undefined, allActions, @@ -1179,15 +1186,15 @@ class DataImportExport extends CoreBase { } /** @type {Record} */ - const instanceLabelRemap = {} + const connectionLabelRemap = {} /** @type {Record} */ - const instanceIdRemap = {} + const connectionIdRemap = {} for (const [oldId, info] of Object.entries(instanceIdMap)) { if (info.oldLabel && info.label !== info.oldLabel) { - instanceLabelRemap[info.oldLabel] = info.label + connectionLabelRemap[info.oldLabel] = info.label } if (info.id && info.id !== oldId) { - instanceIdRemap[oldId] = info.id + connectionIdRemap[oldId] = info.id } } @@ -1248,8 +1255,8 @@ class DataImportExport extends CoreBase { this.fixupControlReferences( { - instanceLabels: instanceLabelRemap, - instanceIds: instanceIdRemap, + connectionLabels: connectionLabelRemap, + connectionIds: connectionIdRemap, }, result.style, allActions, @@ -1312,7 +1319,7 @@ class DataImportExport extends CoreBase { * @returns {boolean} Whether any changes were made */ fixupControlReferences(updateMaps, style, actions, feedbacks, events, recheckChangedFeedbacks) { - const visitor = new VisitorReferencesUpdater(updateMaps.instanceLabels, updateMaps.instanceIds) + const visitor = new VisitorReferencesUpdater(updateMaps.connectionLabels, updateMaps.connectionIds) this.visitControlReferences(visitor, style, actions, feedbacks, events) @@ -1332,8 +1339,8 @@ export default DataImportExport * @typedef {Record} InstanceAppliedRemappings * * @typedef {{ - * instanceLabels?: Record - * instanceIds?: Record + * connectionLabels?: Record + * connectionIds?: Record * }} FixupReferencesUpdateMaps * * @typedef {{ diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index 61f1dbdcad..61ad16e10d 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -168,7 +168,7 @@ class Instance extends CoreBase { if (newLabel && entry.label != newLabel) { const oldLabel = entry.label entry.label = newLabel - this.variable.instanceLabelRename(oldLabel, newLabel) + this.variable.connectionLabelRename(oldLabel, newLabel) this.definitions.updateVariablePrefixesForLabel(id, newLabel) } @@ -415,7 +415,7 @@ class Instance extends CoreBase { if (this.io.countRoomMembers(InstancesRoom) > 0) { const patch = jsonPatch.compare(this.#lastClientJson || {}, newJson || {}) if (patch.length > 0) { - this.io.emitToRoom(InstancesRoom, `instances:patch`, patch) + this.io.emitToRoom(InstancesRoom, `connections:patch`, patch) } } @@ -457,20 +457,20 @@ class Instance extends CoreBase { /** * Get the status of an instance - * @param {String} instance_id + * @param {String} connectionId * @returns {import('./Status.js').StatusEntry} */ - getInstanceStatus(instance_id) { - return this.status.getInstanceStatus(instance_id) + getInstanceStatus(connectionId) { + return this.status.getInstanceStatus(connectionId) } /** * Get the config object of an instance - * @param {String} instance_id + * @param {String} connectionId * @returns {ConnectionConfig | undefined} */ - getInstanceConfig(instance_id) { - return this.store.db[instance_id] + getInstanceConfig(connectionId) { + return this.store.db[connectionId] } /** @@ -522,16 +522,16 @@ class Instance extends CoreBase { this.status.clientConnect(client) this.modules.clientConnect(client) - client.onPromise('instances:subscribe', () => { + client.onPromise('connections:subscribe', () => { client.join(InstancesRoom) return this.#lastClientJson || this.getClientJson() }) - client.onPromise('instances:unsubscribe', () => { + client.onPromise('connections:unsubscribe', () => { client.leave(InstancesRoom) }) - client.onPromise('instances:edit', async (/** @type {string} */ id) => { + client.onPromise('connections:edit', async (/** @type {string} */ id) => { let instance = this.instance.moduleHost.getChild(id) if (!instance) { @@ -566,7 +566,7 @@ class Instance extends CoreBase { }) client.onPromise( - 'instances:set-config', + 'connections:set-config', (/** @type {string} */ id, /** @type {string} */ label, /** @type {object} */ config) => { const idUsingLabel = this.getIdForLabel(label) if (idUsingLabel && idUsingLabel !== id) { @@ -583,26 +583,26 @@ class Instance extends CoreBase { } ) - client.onPromise('instances:set-enabled', (/** @type {string} */ id, /** @type {boolean} */ state) => { + client.onPromise('connections:set-enabled', (/** @type {string} */ id, /** @type {boolean} */ state) => { this.enableDisableInstance(id, !!state) }) - client.onPromise('instances:delete', async (/** @type {string} */ id) => { + client.onPromise('connections:delete', async (/** @type {string} */ id) => { await this.deleteInstance(id) }) - client.onPromise('instances:add', (/** @type {CreateConnectionData} */ module) => { + client.onPromise('connections:add', (/** @type {CreateConnectionData} */ module) => { const id = this.addInstance(module, false) return id }) - client.onPromise('instances:set-order', async (/** @type {string[]} */ instanceIds) => { - if (!Array.isArray(instanceIds)) throw new Error('Expected array of ids') + client.onPromise('connections:set-order', async (/** @type {string[]} */ connectionIds) => { + if (!Array.isArray(connectionIds)) throw new Error('Expected array of ids') // This is a bit naive, but should be sufficient if the client behaves // Update the order based on the ids provided - instanceIds.forEach((id, index) => { + connectionIds.forEach((id, index) => { const entry = this.store.db[id] if (entry) entry.sortOrder = index }) @@ -611,9 +611,9 @@ class Instance extends CoreBase { const allKnownIds = Object.entries(this.store.db) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) .map(([id]) => id) - let nextIndex = instanceIds.length + let nextIndex = connectionIds.length for (const id of allKnownIds) { - if (!instanceIds.includes(id)) { + if (!connectionIds.includes(id)) { const entry = this.store.db[id] if (entry) entry.sortOrder = nextIndex++ } diff --git a/lib/Instance/Definitions.js b/lib/Instance/Definitions.js index 6661c22a8b..7dae8d28c8 100644 --- a/lib/Instance/Definitions.js +++ b/lib/Instance/Definitions.js @@ -163,20 +163,20 @@ class InstanceDefinitions extends CoreBase { /** * Create a feedback item without saving for the UI - * @param {string} instanceId - the id of the instance + * @param {string} connectionId - the id of the connection * @param {string} feedbackId - the id of the feedback * @param {boolean} booleanOnly - whether the feedback must be boolean * @access public */ - createFeedbackItem(instanceId, feedbackId, booleanOnly) { - const definition = this.getFeedbackDefinition(instanceId, feedbackId) + createFeedbackItem(connectionId, feedbackId, booleanOnly) { + const definition = this.getFeedbackDefinition(connectionId, feedbackId) if (definition) { if (booleanOnly && definition.type !== 'boolean') return null const feedback = { id: nanoid(), type: feedbackId, - instance_id: instanceId, + instance_id: connectionId, options: {}, style: {}, isInverted: false, @@ -229,23 +229,23 @@ class InstanceDefinitions extends CoreBase { /** * Forget all the definitions for an instance - * @param {string} instance_id + * @param {string} connectionId * @access public */ - forgetInstance(instance_id) { - delete this.#presetDefinitions[instance_id] + forgetInstance(connectionId) { + delete this.#presetDefinitions[connectionId] if (this.io.countRoomMembers(PresetsRoom) > 0) { - this.io.emitToRoom(PresetsRoom, 'presets:update', instance_id, undefined) + this.io.emitToRoom(PresetsRoom, 'presets:update', connectionId, undefined) } - delete this.#actionDefinitions[instance_id] + delete this.#actionDefinitions[connectionId] if (this.io.countRoomMembers(ActionsRoom) > 0) { - this.io.emitToRoom(ActionsRoom, 'action-definitions:update', instance_id, undefined) + this.io.emitToRoom(ActionsRoom, 'action-definitions:update', connectionId, undefined) } - delete this.#feedbackDefinitions[instance_id] + delete this.#feedbackDefinitions[connectionId] if (this.io.countRoomMembers(FeedbacksRoom) > 0) { - this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', instance_id, undefined) + this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', connectionId, undefined) } } @@ -347,57 +347,57 @@ class InstanceDefinitions extends CoreBase { } /** - * Set the action definitions for an instance - * @param {string} instanceId + * Set the action definitions for a connection + * @param {string} connectionId * @param {Record} actionDefinitions * @access public */ - setActionDefinitions(instanceId, actionDefinitions) { - const lastActionDefinitions = this.#actionDefinitions[instanceId] - this.#actionDefinitions[instanceId] = cloneDeep(actionDefinitions) + setActionDefinitions(connectionId, actionDefinitions) { + const lastActionDefinitions = this.#actionDefinitions[connectionId] + this.#actionDefinitions[connectionId] = cloneDeep(actionDefinitions) if (this.io.countRoomMembers(ActionsRoom) > 0) { if (!lastActionDefinitions) { - this.io.emitToRoom(ActionsRoom, 'action-definitions:update', instanceId, actionDefinitions) + this.io.emitToRoom(ActionsRoom, 'action-definitions:update', connectionId, actionDefinitions) } else { const patch = jsonPatch.compare(lastActionDefinitions, actionDefinitions || {}) if (patch.length > 0) { - this.io.emitToRoom(ActionsRoom, 'action-definitions:update', instanceId, patch) + this.io.emitToRoom(ActionsRoom, 'action-definitions:update', connectionId, patch) } } } } /** - * Set the feedback definitions for an instance - * @param {string} instanceId - the instance ID + * Set the feedback definitions for a connection + * @param {string} connectionId - the instance ID * @param {Record} feedbackDefinitions - the feedback definitions * @access public */ - setFeedbackDefinitions(instanceId, feedbackDefinitions) { - const lastFeedbackDefinitions = this.#feedbackDefinitions[instanceId] - this.#feedbackDefinitions[instanceId] = cloneDeep(feedbackDefinitions) + setFeedbackDefinitions(connectionId, feedbackDefinitions) { + const lastFeedbackDefinitions = this.#feedbackDefinitions[connectionId] + this.#feedbackDefinitions[connectionId] = cloneDeep(feedbackDefinitions) if (this.io.countRoomMembers(FeedbacksRoom) > 0) { if (!lastFeedbackDefinitions) { - this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', instanceId, feedbackDefinitions) + this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', connectionId, feedbackDefinitions) } else { const patch = jsonPatch.compare(lastFeedbackDefinitions, feedbackDefinitions || {}) if (patch.length > 0) { - this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', instanceId, patch) + this.io.emitToRoom(FeedbacksRoom, 'feedback-definitions:update', connectionId, patch) } } } } /** - * Set the preset definitions for an instance + * Set the preset definitions for a connection * @access public - * @param {string} instance_id + * @param {string} connectionId * @param {string} label * @param {Record} rawPresets */ - setPresetDefinitions(instance_id, label, rawPresets) { + setPresetDefinitions(connectionId, label, rawPresets) { /** @type {Record} */ const newPresets = {} @@ -458,7 +458,7 @@ class InstanceDefinitions extends CoreBase { } } - this.#updateVariablePrefixesAndStoreDefinitions(instance_id, label, newPresets) + this.#updateVariablePrefixesAndStoreDefinitions(connectionId, label, newPresets) } /** @@ -483,23 +483,23 @@ class InstanceDefinitions extends CoreBase { /** * Update all the variables in the presets to reference the supplied label - * @param {string} instance_id + * @param {string} connectionId * @param {string} labelTo */ - updateVariablePrefixesForLabel(instance_id, labelTo) { - if (this.#presetDefinitions[instance_id] !== undefined) { - this.logger.silly('Updating presets for instance ' + labelTo) - this.#updateVariablePrefixesAndStoreDefinitions(instance_id, labelTo, this.#presetDefinitions[instance_id]) + updateVariablePrefixesForLabel(connectionId, labelTo) { + if (this.#presetDefinitions[connectionId] !== undefined) { + this.logger.silly('Updating presets for connection ' + labelTo) + this.#updateVariablePrefixesAndStoreDefinitions(connectionId, labelTo, this.#presetDefinitions[connectionId]) } } /** * Update all the variables in the presets to reference the supplied label, and store them - * @param {string} instanceId + * @param {string} connectionId * @param {string} label * @param {Record} presets */ - #updateVariablePrefixesAndStoreDefinitions(instanceId, label, presets) { + #updateVariablePrefixesAndStoreDefinitions(connectionId, label, presets) { const variableRegex = /\$\(([^:)]+):([^)]+)\)/g /** @@ -537,18 +537,18 @@ class InstanceDefinitions extends CoreBase { } } - const lastPresetDefinitions = this.#presetDefinitions[instanceId] - this.#presetDefinitions[instanceId] = cloneDeep(presets) + const lastPresetDefinitions = this.#presetDefinitions[connectionId] + this.#presetDefinitions[connectionId] = cloneDeep(presets) if (this.io.countRoomMembers(PresetsRoom) > 0) { const newSimplifiedPresets = this.#simplifyPresetsForUi(presets) if (!lastPresetDefinitions) { - this.io.emitToRoom(PresetsRoom, 'presets:update', instanceId, newSimplifiedPresets) + this.io.emitToRoom(PresetsRoom, 'presets:update', connectionId, newSimplifiedPresets) } else { const lastSimplifiedPresets = this.#simplifyPresetsForUi(lastPresetDefinitions) const patch = jsonPatch.compare(lastSimplifiedPresets, newSimplifiedPresets) if (patch.length > 0) { - this.io.emitToRoom(PresetsRoom, 'presets:update', instanceId, patch) + this.io.emitToRoom(PresetsRoom, 'presets:update', connectionId, patch) } } } diff --git a/lib/Instance/Modules.js b/lib/Instance/Modules.js index 19b70ed879..2b3ab84d00 100644 --- a/lib/Instance/Modules.js +++ b/lib/Instance/Modules.js @@ -249,7 +249,7 @@ class InstanceModules extends CoreBase { client.leave(ModulesRoom) }) - client.onPromise('instances:get-help', async (/** @type {string} */ moduleId) => { + client.onPromise('connections:get-help', async (/** @type {string} */ moduleId) => { try { const res = await this.getHelpForModule(moduleId) if (res) { diff --git a/lib/Instance/Status.js b/lib/Instance/Status.js index 63ecc4ef92..4ddb01d1b3 100644 --- a/lib/Instance/Status.js +++ b/lib/Instance/Status.js @@ -59,7 +59,7 @@ export default class Status extends EventEmitter { * @access public */ clientConnect(client) { - client.onPromise('instance_status:get', () => { + client.onPromise('connections:get-statuses', () => { return this.#instanceStatuses }) } @@ -166,7 +166,7 @@ export default class Status extends EventEmitter { const patch = jsonPatch.compare(this.#instanceStatuses || {}, newObj || {}) if (patch.length > 0) { // TODO - make this be a subscription with a dedicated room - this.#io.emit(`instance_status:patch`, patch) + this.#io.emit(`connections:patch-statuses`, patch) } this.#instanceStatuses = newObj diff --git a/lib/Instance/Variable.js b/lib/Instance/Variable.js index 55cf2bb4b0..6b134dd10a 100644 --- a/lib/Instance/Variable.js +++ b/lib/Instance/Variable.js @@ -247,7 +247,7 @@ class InstanceVariable extends CoreBase { * @param {string} labelTo * @returns {void} */ - instanceLabelRename(labelFrom, labelTo) { + connectionLabelRename(labelFrom, labelTo) { const valuesTo = this.#variableValues[labelTo] || {} this.#variableValues[labelTo] = valuesTo diff --git a/lib/Internal/ActionRecorder.js b/lib/Internal/ActionRecorder.js index 55d3b49a1c..df3d2a3fd3 100644 --- a/lib/Internal/ActionRecorder.js +++ b/lib/Internal/ActionRecorder.js @@ -192,7 +192,7 @@ export default class ActionRecorder { } else if (action.action === 'action_recorder_set_connections') { const session = this.#actionRecorder.getSession() if (session) { - let result = new Set(session.instanceIds) + let result = new Set(session.connectionIds) const selectedIds = new Set(action.options.connections) @@ -336,9 +336,9 @@ export default class ActionRecorder { let matches = matchAll for (const id of feedback.options.connections) { if (matchAll) { - matches = matches && session.instanceIds.includes(id) + matches = matches && session.connectionIds.includes(id) } else { - matches = matches || session.instanceIds.includes(id) + matches = matches || session.connectionIds.includes(id) } } diff --git a/lib/Internal/Controls.js b/lib/Internal/Controls.js index 9677dfd9bc..3b4fba2eeb 100644 --- a/lib/Internal/Controls.js +++ b/lib/Internal/Controls.js @@ -859,8 +859,8 @@ export default class Controls { const forcePress = !!action.options.force - const [instanceLabel, variableName] = SplitVariableId(action.options.variable) - const variable_value = this.#variableController.getVariableValue(instanceLabel, variableName) + const [connectionLabel, variableName] = SplitVariableId(action.options.variable) + const variable_value = this.#variableController.getVariableValue(connectionLabel, variableName) const condition = this.#variableController.parseVariables(action.options.value).text @@ -970,8 +970,8 @@ export default class Controls { const control = this.#controlsController.getControl(theControlId) - const [instanceLabel, variableName] = SplitVariableId(action.options.variable) - const variable_value = this.#variableController.getVariableValue(instanceLabel, variableName) + const [connectionLabel, variableName] = SplitVariableId(action.options.variable) + const variable_value = this.#variableController.getVariableValue(connectionLabel, variableName) const condition = this.#variableController.parseVariables(action.options.value).text diff --git a/lib/Internal/CustomVariables.js b/lib/Internal/CustomVariables.js index 192d55a2b9..c51d880710 100644 --- a/lib/Internal/CustomVariables.js +++ b/lib/Internal/CustomVariables.js @@ -290,8 +290,8 @@ export default class CustomVariables { this.#variableController.custom.setValueToExpression(action.options.name, action.options.expression) return true } else if (action.action === 'custom_variable_store_variable') { - const [instanceLabel, variableName] = SplitVariableId(action.options.variable) - const value = this.#variableController.getVariableValue(instanceLabel, variableName) + const [connectionLabel, variableName] = SplitVariableId(action.options.variable) + const value = this.#variableController.getVariableValue(connectionLabel, variableName) this.#variableController.custom.setValue(action.options.name, value) return true } else if (action.action === 'custom_variable_set_via_jsonpath') { diff --git a/lib/Registry.js b/lib/Registry.js index 9c70831e45..82f9d3118f 100644 --- a/lib/Registry.js +++ b/lib/Registry.js @@ -253,7 +253,7 @@ class Registry extends EventEmitter { this.internalModule.init() this.graphics.regenerateAll(false) - // We are ready to start the instances + // We are ready to start the instances/connections await this.instance.initInstances(extraModulePath) // Instances are loaded, start up http diff --git a/lib/Util/Visitors/ReferencesCollector.js b/lib/Util/Visitors/ReferencesCollector.js index 6bb85eeefc..8ca7bad4bc 100644 --- a/lib/Util/Visitors/ReferencesCollector.js +++ b/lib/Util/Visitors/ReferencesCollector.js @@ -10,7 +10,7 @@ export class VisitorReferencesCollector { * @access public * @readonly */ - instanceLabels + connecionLabels /** * Referenced instance ids @@ -18,15 +18,15 @@ export class VisitorReferencesCollector { * @access public * @readonly */ - instanceIds + connectionIds /** - * @param {Set | undefined} foundInstanceIds - * @param {Set | undefined} foundInstanceLabels + * @param {Set | undefined} foundConnectionIds + * @param {Set | undefined} foundConnectionLabels */ - constructor(foundInstanceIds, foundInstanceLabels) { - this.instanceLabels = foundInstanceLabels || new Set() - this.instanceIds = foundInstanceIds || new Set() + constructor(foundConnectionIds, foundConnectionLabels) { + this.connecionLabels = foundConnectionLabels || new Set() + this.connectionIds = foundConnectionIds || new Set() } /** @@ -36,7 +36,7 @@ export class VisitorReferencesCollector { * @param {string=} _feedbackId */ visitInstanceId(obj, propName, _feedbackId) { - this.instanceIds.add(obj[propName]) + this.connectionIds.add(obj[propName]) } /** * Visit an instance id array property @@ -46,7 +46,7 @@ export class VisitorReferencesCollector { */ visitInstanceIdArray(obj, propName, _feedbackId) { for (const id of obj[propName]) { - this.instanceIds.add(id) + this.connectionIds.add(id) } } @@ -64,7 +64,7 @@ export class VisitorReferencesCollector { const matches = rawStr.matchAll(reg) for (const match of matches) { - this.instanceLabels.add(match[1]) + this.connecionLabels.add(match[1]) } } @@ -75,6 +75,6 @@ export class VisitorReferencesCollector { */ visitVariableName(obj, propName) { const label = SplitVariableId(obj[propName])[0] - this.instanceLabels.add(label) + this.connecionLabels.add(label) } } diff --git a/lib/Util/Visitors/ReferencesUpdater.js b/lib/Util/Visitors/ReferencesUpdater.js index 2e4db6d0aa..c46417e16f 100644 --- a/lib/Util/Visitors/ReferencesUpdater.js +++ b/lib/Util/Visitors/ReferencesUpdater.js @@ -10,7 +10,7 @@ export class VisitorReferencesUpdater { * @access public * @readonly */ - instanceLabelsRemap + connectionLabelsRemap /** * Instance id remapping @@ -18,7 +18,7 @@ export class VisitorReferencesUpdater { * @access public * @readonly */ - instanceIdRemap + connectionIdRemap /** * Feedback ids that have been changed @@ -36,12 +36,12 @@ export class VisitorReferencesUpdater { changed = false /** - * @param {Record | undefined} instanceLabelsRemap - * @param {Record | undefined} instanceIdRemap + * @param {Record | undefined} connectionLabelsRemap + * @param {Record | undefined} connectionIdRemap */ - constructor(instanceLabelsRemap, instanceIdRemap) { - this.instanceLabelsRemap = instanceLabelsRemap - this.instanceIdRemap = instanceIdRemap + constructor(connectionLabelsRemap, connectionIdRemap) { + this.connectionLabelsRemap = connectionLabelsRemap + this.connectionIdRemap = connectionIdRemap } /** @@ -60,10 +60,10 @@ export class VisitorReferencesUpdater { * @param {string=} feedbackId */ visitInstanceId(obj, propName, feedbackId) { - if (!this.instanceIdRemap) return + if (!this.connectionIdRemap) return const oldId = obj[propName] - const newId = this.instanceIdRemap[oldId] + const newId = this.connectionIdRemap[oldId] if (newId && newId !== oldId) { obj[propName] = newId @@ -77,7 +77,7 @@ export class VisitorReferencesUpdater { * @param {string=} feedbackId */ visitInstanceIdArray(obj, propName, feedbackId) { - if (!this.instanceIdRemap) return + if (!this.connectionIdRemap) return const array = obj[propName] for (let i = 0; i < array.length; i++) { @@ -92,9 +92,9 @@ export class VisitorReferencesUpdater { * @param {string=} feedbackId */ visitString(obj, propName, feedbackId) { - if (!this.instanceLabelsRemap) return + if (!this.connectionLabelsRemap) return - const labelsRemap = this.instanceLabelsRemap + const labelsRemap = this.connectionLabelsRemap const rawStr = obj[propName] if (typeof rawStr !== 'string') return @@ -133,10 +133,10 @@ export class VisitorReferencesUpdater { * @param {string=} feedbackId */ visitVariableName(obj, propName, feedbackId) { - if (!this.instanceLabelsRemap) return + if (!this.connectionLabelsRemap) return const id = SplitVariableId(obj[propName]) - const newLabel = this.instanceLabelsRemap[id[0]] + const newLabel = this.connectionLabelsRemap[id[0]] if (newLabel) { obj[propName] = `${newLabel}:${id[1]}` diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 640f682c08..782fb946e3 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -35,7 +35,7 @@ import { TouchBackend } from 'react-dnd-touch-backend' import { MySidebar } from './Layout/Sidebar' import { MyHeader } from './Layout/Header' import { Triggers } from './Triggers' -import { InstancesPage } from './Instances' +import { ConnectionsPage } from './Connections' import { ButtonsPage } from './Buttons' import { ContextData } from './ContextData' import { CloudPage } from './CloudPage' @@ -444,7 +444,7 @@ function AppContent({ buttonGridHotPress }) { - + diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.jsx index 22eae19f9d..29502f0269 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useState, useRef } from 'react' import { - InstancesContext, + ConnectionsContext, socketEmitPromise, SocketContext, LoadingRetryOrError, @@ -518,7 +518,7 @@ function TriggerPicker({ selectControl }) { function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }) { const socket = useContext(SocketContext) - const instances = useContext(InstancesContext) + const instances = useContext(ConnectionsContext) const doClearActions = useCallback(() => { socketEmitPromise(socket, 'action-recorder:session:discard-actions', [sessionId]).catch((e) => { @@ -593,7 +593,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }
Connections { if (!vals || Object.values(vals).length === 0) return '' - const instance = instancesContext[id] + const instance = connectionsContext[id] const module = instance ? modules[instance.instance_type] : undefined return ( diff --git a/webui/src/Buttons/Variables.jsx b/webui/src/Buttons/Variables.jsx index b3a87b894b..21fe1c3930 100644 --- a/webui/src/Buttons/Variables.jsx +++ b/webui/src/Buttons/Variables.jsx @@ -1,22 +1,22 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { CButton } from '@coreui/react' -import { InstancesContext, VariableDefinitionsContext, ModulesContext } from '../util' +import { ConnectionsContext, VariableDefinitionsContext, ModulesContext } from '../util' import { VariablesTable } from '../Components/VariablesTable' import { CustomVariablesList } from './CustomVariablesList' export const InstanceVariables = function InstanceVariables({ resetToken }) { - const instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) const [instanceId, setInstance] = useState(null) const [showCustom, setShowCustom] = useState(false) const instancesLabelMap = useMemo(() => { const labelMap = new Map() - for (const [id, instance] of Object.entries(instancesContext)) { + for (const [id, instance] of Object.entries(connectionsContext)) { labelMap.set(instance.label, id) } return labelMap - }, [instancesContext]) + }, [connectionsContext]) // Reset selection on resetToken change useEffect(() => { @@ -26,10 +26,10 @@ export const InstanceVariables = function InstanceVariables({ resetToken }) { if (showCustom) { return } else if (instanceId) { - let instanceLabel = instancesContext[instanceId]?.label - if (instanceId === 'internal') instanceLabel = 'internal' + let connectionLabel = connectionsContext[instanceId]?.label + if (instanceId === 'internal') connectionLabel = 'internal' - return + return } else { return ( { @@ -60,7 +60,7 @@ function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap } } const id = instancesLabelMap.get(label) - const instance = id ? instancesContext[id] : undefined + const instance = id ? connectionsContext[id] : undefined const module = instance ? modules[instance.instance_type] : undefined return ( @@ -86,7 +86,7 @@ function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap } ) } -function VariablesList({ selectedInstanceLabel, setInstance }) { +function VariablesList({ selectedConnectionLabel, setInstance }) { const doBack = useCallback(() => setInstance(null), [setInstance]) return ( @@ -95,10 +95,10 @@ function VariablesList({ selectedInstanceLabel, setInstance }) { Back - Variables for {selectedInstanceLabel} + Variables for {selectedConnectionLabel} - +
diff --git a/webui/src/Components/TextInputField.jsx b/webui/src/Components/TextInputField.jsx index bc1f80159b..2104b47427 100644 --- a/webui/src/Components/TextInputField.jsx +++ b/webui/src/Components/TextInputField.jsx @@ -39,9 +39,9 @@ export function TextInputField({ // Update the suggestions list in tribute whenever anything changes const suggestions = [] if (useVariables) { - for (const [instanceLabel, variables] of Object.entries(variableDefinitionsContext)) { + for (const [connectionLabel, variables] of Object.entries(variableDefinitionsContext)) { for (const [name, va] of Object.entries(variables || {})) { - const variableId = `${instanceLabel}:${name}` + const variableId = `${connectionLabel}:${name}` suggestions.push({ key: variableId + ')', value: variableId, diff --git a/webui/src/ConnectionDebug.jsx b/webui/src/ConnectionDebug.jsx index 34e69598f8..f0af07e2f1 100644 --- a/webui/src/ConnectionDebug.jsx +++ b/webui/src/ConnectionDebug.jsx @@ -101,12 +101,12 @@ export function ConnectionDebug() { }, [linesBuffer]) const doStopConnection = useCallback(() => { - socketEmitPromise(socket, 'instances:set-enabled', [connectionId, false]).catch((e) => { + socketEmitPromise(socket, 'connections:set-enabled', [connectionId, false]).catch((e) => { console.error('Failed', e) }) }, [socket, connectionId]) const doStartConnection = useCallback(() => { - socketEmitPromise(socket, 'instances:set-enabled', [connectionId, true]).catch((e) => { + socketEmitPromise(socket, 'connections:set-enabled', [connectionId, true]).catch((e) => { console.error('Failed', e) }) }, [socket, connectionId]) diff --git a/webui/src/Instances/AddInstance.jsx b/webui/src/Connections/AddConnection.jsx similarity index 84% rename from webui/src/Instances/AddInstance.jsx rename to webui/src/Connections/AddConnection.jsx index b07452325a..280543dbac 100644 --- a/webui/src/Instances/AddInstance.jsx +++ b/webui/src/Connections/AddConnection.jsx @@ -8,15 +8,15 @@ import { useCallback } from 'react' import { useRef } from 'react' import { GenericConfirmModal } from '../Components/GenericConfirmModal' -export function AddInstancesPanel({ showHelp, doConfigureInstance }) { +export function AddConnectionsPanel({ showHelp, doConfigureConnection }) { return ( <> - + ) } -const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureInstance }) { +const AddConnectionsInner = memo(function AddConnectionsInner({ showHelp, configureConnection }) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const modules = useContext(ModulesContext) @@ -24,23 +24,23 @@ const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureI const confirmRef = useRef(null) - const addInstanceInner = useCallback( + const addConnectionInner = useCallback( (type, product) => { - socketEmitPromise(socket, 'instances:add', [{ type: type, product: product }]) + socketEmitPromise(socket, 'connections:add', [{ type: type, product: product }]) .then((id) => { setFilter('') - console.log('NEW INSTANCE', id) - configureInstance(id) + console.log('NEW CONNECTION', id) + configureConnection(id) }) .catch((e) => { notifier.current.show(`Failed to create connection`, `Failed: ${e}`) console.error('Failed to create connection:', e) }) }, - [socket, notifier, configureInstance] + [socket, notifier, configureConnection] ) - const addInstance = useCallback( + const addConnection = useCallback( (type, product, module) => { if (module.isLegacy) { confirmRef.current.show( @@ -48,14 +48,14 @@ const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureI null, // Passed as param to the thing 'Add anyway', () => { - addInstanceInner(type, product) + addConnectionInner(type, product) } ) } else { - addInstanceInner(type, product) + addConnectionInner(type, product) } }, - [addInstanceInner] + [addConnectionInner] ) const allProducts = useMemo(() => { @@ -76,7 +76,7 @@ const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureI for (const module of searchResults) { candidatesObj[module.name] = (
- addInstance(module.id, module.product, module)}> + addConnection(module.id, module.product, module)}> Add   @@ -170,7 +170,7 @@ const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureI
-
{candidates}
+
{candidates}
) }) diff --git a/webui/src/Instances/InstanceEditPanel.jsx b/webui/src/Connections/ConnectionEditPanel.jsx similarity index 78% rename from webui/src/Instances/InstanceEditPanel.jsx rename to webui/src/Connections/ConnectionEditPanel.jsx index 7d8fb7d37c..d9260ac984 100644 --- a/webui/src/Instances/InstanceEditPanel.jsx +++ b/webui/src/Connections/ConnectionEditPanel.jsx @@ -10,12 +10,12 @@ import { isLabelValid } from '@companion/shared/Label' import CSwitch from '../CSwitch' import { BonjourDeviceInputField } from '../Components/BonjourDeviceInputField' -export function InstanceEditPanel({ instanceId, instanceStatus, doConfigureInstance, showHelp }) { - console.log('status', instanceStatus) +export function ConnectionEditPanel({ connectionId, connectionStatus, doConfigureConnection, showHelp }) { + console.log('status', connectionStatus) - if (!instanceStatus || !instanceStatus.level || instanceStatus.level === 'crashed') { + if (!connectionStatus || !connectionStatus.level || connectionStatus.level === 'crashed') { return ( - +

Waiting for connection to start...

@@ -25,11 +25,19 @@ export function InstanceEditPanel({ instanceId, instanceStatus, doConfigureInsta } return ( - + ) } -const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doConfigureInstance, showHelp }) { +const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ + connectionId, + doConfigureConnection, + showHelp, +}) { const socket = useContext(SocketContext) const modules = useContext(ModulesContext) @@ -37,9 +45,9 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC const [reloadToken, setReloadToken] = useState(nanoid()) const [configFields, setConfigFields] = useState(null) - const [instanceConfig, setInstanceConfig] = useState(null) - const [instanceLabel, setInstanceLabel] = useState(null) - const [instanceType, setInstanceType] = useState(null) + const [connectionConfig, setConnectionConfig] = useState(null) + const [connectionLabel, setConnectionLabel] = useState(null) + const [connectionType, setConnectionType] = useState(null) const [validFields, setValidFields] = useState(null) const [fieldVisibility, setFieldVisibility] = useState({}) @@ -59,21 +67,21 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC }, [validFields, fieldVisibility]) const doCancel = useCallback(() => { - doConfigureInstance(null) + doConfigureConnection(null) setConfigFields([]) - }, [doConfigureInstance]) + }, [doConfigureConnection]) const doSave = useCallback(() => { setError(null) - const newLabel = instanceLabel?.trim() + const newLabel = connectionLabel?.trim() if (!isLabelValid(newLabel) || invalidFieldNames.length > 0) { setError(`Some config fields are not valid: ${invalidFieldNames.join(', ')}`) return } - socketEmitPromise(socket, 'instances:set-config', [instanceId, newLabel, instanceConfig]) + socketEmitPromise(socket, 'connections:set-config', [connectionId, newLabel, connectionConfig]) .then((err) => { if (err) { if (err === 'invalid label') { @@ -91,11 +99,11 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC .catch((e) => { setError(`Failed to save connection config: ${e}`) }) - }, [socket, instanceId, invalidFieldNames, instanceLabel, instanceConfig, doCancel]) + }, [socket, connectionId, invalidFieldNames, connectionLabel, connectionConfig, doCancel]) useEffect(() => { - if (instanceId) { - socketEmitPromise(socket, 'instances:edit', [instanceId]) + if (connectionId) { + socketEmitPromise(socket, 'connections:edit', [connectionId]) .then((res) => { if (res) { const validFields = {} @@ -110,9 +118,9 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC } setConfigFields(res.fields) - setInstanceLabel(res.label) - setInstanceType(res.instance_type) - setInstanceConfig(res.config) + setConnectionLabel(res.label) + setConnectionType(res.instance_type) + setConnectionConfig(res.config) setValidFields(validFields) } else { setError(`Connection config unavailable`) @@ -126,18 +134,18 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC return () => { setError(null) setConfigFields(null) - setInstanceLabel(null) - setInstanceConfig(null) + setConnectionLabel(null) + setConnectionConfig(null) setValidFields(null) } - }, [socket, instanceId, reloadToken]) + }, [socket, connectionId, reloadToken]) const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) const setValue = useCallback((key, value) => { console.log('set value', key, value) - setInstanceConfig((oldConfig) => ({ + setConnectionConfig((oldConfig) => ({ ...oldConfig, [key]: value, })) @@ -154,12 +162,12 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC useEffect(() => { const visibility = {} - if (configFields === null || instanceConfig === null) { + if (configFields === null || connectionConfig === null) { return } for (const field of configFields) { if (typeof field.isVisible === 'function') { - visibility[field.id] = field.isVisible(instanceConfig, field.isVisibleData) + visibility[field.id] = field.isVisible(connectionConfig, field.isVisibleData) } } @@ -168,27 +176,31 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC return () => { setFieldVisibility({}) } - }, [configFields, instanceConfig]) + }, [configFields, connectionConfig]) - const moduleInfo = modules[instanceType] ?? {} - const dataReady = instanceConfig && configFields && validFields + const moduleInfo = modules[connectionType] ?? {} + const dataReady = connectionConfig && configFields && validFields return (
- {moduleInfo?.shortname ?? instanceType} configuration + {moduleInfo?.shortname ?? connectionType} configuration {moduleInfo?.hasHelp && ( -
showHelp(instanceType)}> +
showHelp(connectionType)}>
)}
- + - {instanceId && dataReady && ( + {connectionId && dataReady && ( <> - + {configFields.map((field, i) => { @@ -205,11 +217,11 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC )} ) @@ -222,7 +234,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC 0 || !isLabelValid(instanceLabel)} + disabled={!validFields || invalidFieldNames.length > 0 || !isLabelValid(connectionLabel)} onClick={doSave} > Save diff --git a/webui/src/Instances/InstanceList.jsx b/webui/src/Connections/ConnectionList.jsx similarity index 76% rename from webui/src/Instances/InstanceList.jsx rename to webui/src/Connections/ConnectionList.jsx index a8b22b433f..15a7f002ee 100644 --- a/webui/src/Instances/InstanceList.jsx +++ b/webui/src/Connections/ConnectionList.jsx @@ -1,6 +1,12 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { CButton, CButtonGroup } from '@coreui/react' -import { InstancesContext, VariableDefinitionsContext, socketEmitPromise, SocketContext, ModulesContext } from '../util' +import { + ConnectionsContext, + VariableDefinitionsContext, + socketEmitPromise, + SocketContext, + ModulesContext, +} from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, @@ -14,27 +20,27 @@ import { faEyeSlash, } from '@fortawesome/free-solid-svg-icons' -import { InstanceVariablesModal } from './InstanceVariablesModal' +import { ConnectionVariablesModal } from './ConnectionVariablesModal' import { GenericConfirmModal } from '../Components/GenericConfirmModal' import CSwitch from '../CSwitch' import { useDrag, useDrop } from 'react-dnd' import { windowLinkOpen } from '../Helpers/Window' import classNames from 'classnames' -export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, selectedInstanceId }) { +export function ConnectionsList({ showHelp, doConfigureConnection, connectionStatus, selectedConnectionId }) { const socket = useContext(SocketContext) - const instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) - const instancesRef = useRef(null) + const connectionsRef = useRef(null) useEffect(() => { - instancesRef.current = instancesContext - }, [instancesContext]) + connectionsRef.current = connectionsContext + }, [connectionsContext]) const deleteModalRef = useRef() const variablesModalRef = useRef() - const doShowVariables = useCallback((instanceId) => { - variablesModalRef.current.show(instanceId) + const doShowVariables = useCallback((connectionId) => { + variablesModalRef.current.show(connectionId) }, []) const [visibleConnections, setVisibleConnections] = useState(() => loadVisibility()) @@ -58,8 +64,8 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s const moveRow = useCallback( (itemId, targetId) => { - if (instancesRef.current) { - const rawIds = Object.entries(instancesRef.current) + if (connectionsRef.current) { + const rawIds = Object.entries(connectionsRef.current) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) .map(([id]) => id) @@ -70,7 +76,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s const newIds = rawIds.filter((id) => id !== itemId) newIds.splice(targetIndex, 0, itemId) - socketEmitPromise(socket, 'instances:set-order', [newIds]).catch((e) => { + socketEmitPromise(socket, 'connections:set-order', [newIds]).catch((e) => { console.error('Reorder failed', e) }) } @@ -80,12 +86,12 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s let visibleCount = 0 - const rows = Object.entries(instancesContext) + const rows = Object.entries(connectionsContext) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) - .map(([id, instance]) => { - const status = instanceStatus?.[id] + .map(([id, connection]) => { + const status = connectionStatus?.[id] - if (!visibleConnections.disabled && instance.enabled === false) { + if (!visibleConnections.disabled && connection.enabled === false) { return undefined } else if (status) { if (!visibleConnections.ok && status.category === 'good') { @@ -100,21 +106,21 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s visibleCount++ return ( - ) }) - const hiddenCount = Object.keys(instancesContext).length - visibleCount + const hiddenCount = Object.keys(connectionsContext).length - visibleCount return (
@@ -126,7 +132,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s

- + @@ -183,7 +189,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s )} - {Object.keys(instancesContext).length === 0 && ( + {Object.keys(connectionsContext).length === 0 && ( - + + + + + + + + + + + + + + + ) +} diff --git a/webui/src/UserConfig/HttpProtocol.jsx b/webui/src/UserConfig/HttpProtocol.jsx index 0ae148d529..c71f1bbe9c 100644 --- a/webui/src/UserConfig/HttpProtocol.jsx +++ b/webui/src/UserConfig/HttpProtocol.jsx @@ -7,12 +7,63 @@ export function HttpProtocol() {

Commands:

+

+ This API tries to follow REST principles, and the convention that a POST request will modify a + value, and a GET request will retrieve values. +

  • - /press/bank/<page>/<button> + Press and release a button (run both down and up actions)
    - Press and release a button (run both down and up actions) + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /press +
  • +
  • + Press the button (run down actions and hold) +
    + Method: POST +
    + Path: /api/location/<page>/ + <row>/<column> + /down +
  • +
  • + Release the button (run up actions) +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /up +
  • +
  • + Trigger a left rotation of the button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /rotate-left +
  • +
  • + Trigger a right rotation of the button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /rotate-right
  • +
  • + Set the current step of a button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /step?step=<step> +
  • + +
    +
  • /style/bank/<page>/<button> ?bgcolor=<bgcolor HEX> @@ -37,13 +88,19 @@ export function HttpProtocol() {
    Change text size on a button (between the predefined values)
  • +
  • - /set/custom-variable/<name>?value=<value> + POST /api/custom-variable/<name>/value?value=<value>
    Change custom variable value
  • - /rescan + POST /api/custom-variable/<name>/value <value> +
    + Change custom variable value +
  • +
  • + POST /surfaces/rescan
    Make Companion rescan for newly attached USB surfaces
  • @@ -77,6 +134,55 @@ export function HttpProtocol() {
    /set/custom-variable/cue?value=intro

    + +

    + Deprecated Commands: +

    +

    + The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

    +
      +
    • + /press/bank/<page>/<button> +
      + Press and release a button (run both down and up actions) +
    • +
    • + /style/bank/<page>/<button> + ?bgcolor=<bgcolor HEX> +
      + Change background color of button +
    • +
    • + /style/bank/<page>/<button> + ?color=<color HEX> +
      + Change color of text on button +
    • +
    • + /style/bank/<page>/<button> + ?text=<text> +
      + Change text on a button +
    • +
    • + /style/bank/<page>/<button> + ?size=<text size> +
      + Change text size on a button (between the predefined values) +
    • +
    • + /set/custom-variable/<name>?value=<value> +
      + Change custom variable value +
    • +
    • + /rescan +
      + Make Companion rescan for newly attached USB surfaces +
    • +
    ) } diff --git a/webui/src/UserConfig/OscConfig.jsx b/webui/src/UserConfig/OscConfig.jsx index 69e2e85f2e..e1cb3ad39a 100644 --- a/webui/src/UserConfig/OscConfig.jsx +++ b/webui/src/UserConfig/OscConfig.jsx @@ -47,6 +47,28 @@ export function OscConfig({ config, setValue, resetValue }) { +
+ + + + ) } diff --git a/webui/src/UserConfig/OscProtocol.jsx b/webui/src/UserConfig/OscProtocol.jsx index e2854f2bc4..5c1f44d673 100644 --- a/webui/src/UserConfig/OscProtocol.jsx +++ b/webui/src/UserConfig/OscProtocol.jsx @@ -20,44 +20,77 @@ export function OscProtocol() {

  • - /press/bank/<page>/<button> + /location/<page>/<row>/<column>/press
    Press and release a button (run both down and up actions)
  • - /press/bank/<page>/<button> <1> + /location/<page>/<row>/<column>/down
    Press the button (run down actions and hold)
  • - /press/bank/<page>/<button> <0> + /location/<page>/<row>/<column>/up
    Release the button (run up actions)
  • - /style/bgcolor/<page>/<button> <red 0-255> <green 0-255> - <blue 0-255> + /location/<page>/<row>/<column> + /rotate-left +
    + Trigger a left rotation of the button/encoder +
  • +
  • + /location/<page>/<row>/<column> + /rotate-right +
    + Trigger a right rotation of the button/encoder +
  • +
  • + /location/<page>/<row>/<column> + /step +
    + Set the current step of a button/encoder +
  • + +
  • + /location/<page>/<row>/<column> + /style/bgcolor <red 0-255> <green 0-255> <blue 0-255>
    Change background color of button
  • - /style/color/<page>/<button> <red 0-255> <green 0-255> - <blue 0-255> + /location/<page>/<row>/<column> + /style/bgcolor <css color> +
    + Change background color of button +
  • +
  • + /location/<page>/<row>/<column> + /style/color <red 0-255> <green 0-255> <blue 0-255>
    Change color of text on button
  • - /style/text/<page>/<button> <text> + /location/<page>/<row>/<column> + /style/color <css color> +
    + Change color of text on button +
  • +
  • + /location/<page>/<row>/<column> + /style/text <text>
    Change text on a button
  • +
  • /custom-variable/<name>/value <value>
    Change custom variable value
  • - /rescan 1 + /surfaces/rescan
    Make Companion rescan for newly attached USB surfaces
  • @@ -68,21 +101,25 @@ export function OscProtocol() {

    - Press button 5 on page 1 down and hold + Press row 0, column 5 on page 1 down and hold
    - /press/bank/1/5 1 + /location/1/0/5/press

    - Change button background color of button 5 on page 1 to red + Change button background color of row 0, column 5 on page 1 to red
    - /style/bgcolor/1/5 255 0 0 + /location/1/0/5/style/bgcolor 255 0 0 +
    + /location/1/0/5/style/bgcolor rgb(255,0,0) +
    + /location/1/0/5/style/bgcolor #ff0000

    - Change the text of button 5 on page 1 to ONLINE + Change the text of row 0, column 5 on page 1 to ONLINE
    - /style/text/1/5 ONLINE + /location/1/0/5/style/text ONLINE

    @@ -90,6 +127,53 @@ export function OscProtocol() {
    /custom-variable/cue/value intro

    + +

    + Deprecated Commands: +

    +

    + The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

    +
      +
    • + /press/bank/<page>/<button> +
      + Press and release a button (run both down and up actions) +
    • +
    • + /press/bank/<page>/<button> <1> +
      + Press the button (run down actions and hold) +
    • +
    • + /press/bank/<page>/<button> <0> +
      + Release the button (run up actions) +
    • +
    • + /style/bgcolor/<page>/<button> <red 0-255> <green 0-255> + <blue 0-255> +
      + Change background color of button +
    • +
    • + /style/color/<page>/<button> <red 0-255> <green 0-255> + <blue 0-255> +
      + Change color of text on button +
    • +
    • + /style/text/<page>/<button> <text> +
      + Change text on a button +
    • +
    • + /rescan 1 +
      + Make Companion rescan for newly attached USB surfaces +
    • +
    ) } diff --git a/webui/src/UserConfig/TcpConfig.jsx b/webui/src/UserConfig/TcpConfig.jsx index f03bfc4f0b..0a222bb5bb 100644 --- a/webui/src/UserConfig/TcpConfig.jsx +++ b/webui/src/UserConfig/TcpConfig.jsx @@ -47,6 +47,28 @@ export function TcpConfig({ config, setValue, resetValue }) { +
+ + + + ) } diff --git a/webui/src/UserConfig/TcpUdpProtocol.jsx b/webui/src/UserConfig/TcpUdpProtocol.jsx index d62871b7e6..50163427a1 100644 --- a/webui/src/UserConfig/TcpUdpProtocol.jsx +++ b/webui/src/UserConfig/TcpUdpProtocol.jsx @@ -4,84 +4,104 @@ import { UserConfigContext } from '../util' export function TcpUdpProtocol() { const config = useContext(UserConfigContext) + const tcpPort = + config?.tcp_enabled && config?.tcp_listen_port && config?.tcp_listen_port !== '0' + ? config?.tcp_listen_port + : 'disabled' + const udpPort = + config?.udp_enabled && config?.udp_listen_port && config?.udp_listen_port !== '0' + ? config?.udp_listen_port + : 'disabled' + return ( <>

- Remote triggering can be done by sending TCP (port{' '} - - {config?.tcp_enabled && config?.tcp_listen_port && config?.tcp_listen_port !== '0' - ? config?.tcp_listen_port - : 'disabled'} - - ) or UDP (port{' '} - - {config?.udp_enabled && config?.udp_listen_port && config?.udp_listen_port !== '0' - ? config?.udp_listen_port - : 'disabled'} - - ) commands. + Remote triggering can be done by sending TCP (port {tcpPort}) or UDP (port {udpPort}) + commands.

Commands:

  • - PAGE-SET <page number> <surface id> + SURFACE <surface id> PAGE-SET <page number>
    - Make device go to a specific page + Set a surface to a specific page
  • - PAGE-UP <surface id> + SURFACE <surface id> PAGE-UP
    - Page up on a specific device + Page up on a specific surface
  • - PAGE-DOWN <surface id> + SURFACE <surface id> PAGE-DOWN
    Page down on a specific surface
  • +
  • - BANK-PRESS <page> <button> + LOCATION <page>/<row>/<column>{' '} + BANK-PRESS
    Press and release a button (run both down and up actions)
  • - BANK-DOWN <page> <button> + LOCATION <page>/<row>/<column> BANK-DOWN
    Press the button (run down actions)
  • - BANK-UP <page> <button> + LOCATION <page>/<row>/<column> BANK-UP
    Release the button (run up actions)
  • - STYLE BANK <page> <button> TEXT - <text> + LOCATION <page>/<row>/<column>{' '} + ROTATE-LEFT +
    + Trigger a left rotation of the button/encode +
  • +
  • + LOCATION <page>/<row>/<column>{' '} + ROTATE-RIGHT +
    + Trigger a right rotation of the button/encode +
  • +
  • + LOCATION <page>/<row>/<column> SET-STEP{' '} + <step> +
    + Set the current step of a button/encoder +
  • + +
  • + LOCATION <page>/<row>/<column>{' '} + STYLE TEXT <text>
    Change text on a button
  • - STYLE BANK <page> <button> - COLOR <color HEX> + LOCATION <page>/<row>/<column>{' '} + STYLE COLOR <color HEX>
    Change text color on a button (#000000)
  • - STYLE BANK <page> <button> - BGCOLOR <color HEX> + LOCATION <page>/<row>/<column>{' '} + STYLE BGCOLOR <color HEX>
    Change background color on a button (#000000)
  • +
  • CUSTOM-VARIABLE <name> SET-VALUE <value>
    Change custom variable value
  • - RESCAN + SURFACES RESCAN
    - Make Companion rescan for newly attached USB surfaces + Make Companion rescan for USB surfaces
@@ -92,13 +112,13 @@ export function TcpUdpProtocol() {

Set the emulator surface to page 23
- PAGE-SET 23 emulator + SURFACE emulator PAGE-SET 23

- Press page 1 button 2 + Press page 1 row 2 column 3
- BANK-PRESS 1 2 + LOCATION 1/2/3 PRESS

@@ -106,6 +126,74 @@ export function TcpUdpProtocol() {
CUSTOM-VARIABLE cue SET-VALUE intro

+ +

+ Deprecated Commands: +

+

+ The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

+
    +
  • + PAGE-SET <page number> <surface id> +
    + Make device go to a specific page +
  • +
  • + PAGE-UP <surface id> +
    + Page up on a specific device +
  • +
  • + PAGE-DOWN <surface id> +
    + Page down on a specific surface +
  • +
  • + BANK-PRESS <page> <button> +
    + Press and release a button (run both down and up actions) +
  • +
  • + BANK-DOWN <page> <button> +
    + Press the button (run down actions) +
  • +
  • + BANK-UP <page> <button> +
    + Release the button (run up actions) +
  • +
  • + STYLE BANK <page> <button> TEXT + <text> +
    + Change text on a button +
  • +
  • + STYLE BANK <page> <button> + COLOR <color HEX> +
    + Change text color on a button (#000000) +
  • +
  • + STYLE BANK <page> <button> + BGCOLOR <color HEX> +
    + Change background color on a button (#000000) +
  • +
  • + CUSTOM-VARIABLE <name> SET-VALUE <value> +
    + Change custom variable value +
  • +
  • + RESCAN +
    + Make Companion rescan for newly attached USB surfaces +
  • +
) } diff --git a/webui/src/UserConfig/UdpConfig.jsx b/webui/src/UserConfig/UdpConfig.jsx index f049e9d1ba..7c82612037 100644 --- a/webui/src/UserConfig/UdpConfig.jsx +++ b/webui/src/UserConfig/UdpConfig.jsx @@ -47,6 +47,28 @@ export function UdpConfig({ config, setValue, resetValue }) { + + + + + ) } diff --git a/webui/src/UserConfig/index.jsx b/webui/src/UserConfig/index.jsx index 5ba3c09379..981c5bf268 100644 --- a/webui/src/UserConfig/index.jsx +++ b/webui/src/UserConfig/index.jsx @@ -21,6 +21,7 @@ import { RosstalkConfig } from './RosstalkConfig' import { ArtnetConfig } from './ArtnetConfig' import { GridConfig } from './GridConfig' import { VideohubServerConfig } from './VideohubServerConfig' +import { HttpConfig } from './HttpConfig' export const UserConfig = memo(function UserConfig() { return ( @@ -75,6 +76,7 @@ function UserConfigTable() { + diff --git a/yarn.lock b/yarn.lock index ff875aa3ab..b938494783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1261,6 +1261,11 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookiejar@*": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.3.tgz#c54976fb8f3a32ea8da844f59f0374dd39656e13" + integrity sha512-LZ8SD3LpNmLMDLkG2oCBjZg+ETnx6XdCjydUE0HwojDmnDfDUnhMKKbtth1TZh+hzcqb03azrYWoXLS8sMXdqg== + "@types/cors@^2.8.12", "@types/cors@^2.8.14": version "2.8.15" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.15.tgz#eb143aa2f8807ddd78e83cbff141bbedd91b60ee" @@ -1550,6 +1555,21 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.2.tgz#01284dde9ef4e6d8cef6422798d9a3ad18a66f8b" integrity sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw== +"@types/superagent@*": + version "4.1.20" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.20.tgz#9248f55ac588794568f02fe9cac6d6ff2650b660" + integrity sha512-GfpwJgYSr3yO+nArFkmyqv3i0vZavyEG5xPd/o95RwpKYpsOKJYI5XLdxLpdRbZI3YiGKKdIOFIf/jlP7A0Jxg== + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.15": + version "2.0.15" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.15.tgz#3d032865048c84c6a3bbbf1f949145b917d2ff65" + integrity sha512-jUCZZ/TMcpGzoSaed9Gjr8HCf3HehExdibyw3OHHEL1als1KmyzcOZZH4MjbObI8TkWsEr7bc7gsW0WTDni+qQ== + dependencies: + "@types/superagent" "*" + "@types/stream-demux@*": version "8.0.1" resolved "https://registry.yarnpkg.com/@types/stream-demux/-/stream-demux-8.0.1.tgz#7e6003fa1590de6d344fced0efe7eadee18d5684" @@ -1964,6 +1984,11 @@ array-flatten@^2.1.2: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1@evs-broadcast/node-asn1: version "0.5.4" resolved "https://codeload.github.com/evs-broadcast/node-asn1/tar.gz/0146823069e479e90595480dc90c72cafa161ba1" @@ -2493,6 +2518,11 @@ commander@^5.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +component-emitter@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + compress-commons@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-5.0.1.tgz#e46723ebbab41b50309b27a0e0f6f3baed2d6590" @@ -2550,6 +2580,11 @@ cookie@~0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -2715,6 +2750,14 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -3063,6 +3106,11 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -3175,6 +3223,16 @@ formdata-polyfill@^4.0.10: dependencies: fetch-blob "^3.1.2" +formidable@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" + integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + qs "^6.11.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3428,6 +3486,11 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -3869,6 +3932,13 @@ jest-message-util@^29.7.0: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-3.0.5.tgz#ebf208e363f4f1db603b81fb005c4055b7c1c8b7" + integrity sha512-/eHdaNPUAXe7f65gHH5urc8SbRVWjYxBqmCgax2uqOBJy8UUcCBMN1upj1eZ8y/i+IqpyEm4Kq0VKss/GCCTdw== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" @@ -4460,7 +4530,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -4490,6 +4560,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -5080,6 +5155,13 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.11.0: + version "6.11.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" + integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -5676,6 +5758,30 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +superagent@^8.0.5: + version "8.1.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" + integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.4" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^2.1.2" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.0" + semver "^7.3.8" + +supertest@^6.3.3: + version "6.3.3" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.3.tgz#42f4da199fee656106fd422c094cf6c9578141db" + integrity sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA== + dependencies: + methods "^1.1.2" + superagent "^8.0.5" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -5818,6 +5924,11 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + tslib@^1.13.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" From 29ff30a66af44512615a247632f63cc6ac31c44b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 15 Nov 2023 23:20:19 +0000 Subject: [PATCH 29/53] fix: 'reset page buttons' wiping page --- lib/Data/ImportExport.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 2f223cccef..64023536cd 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -729,8 +729,6 @@ class DataImportExport extends CoreBase { * @returns {'ok'} */ (pageNumber) => { - this.page.resetPage(pageNumber) - // make magical page buttons! for (const { type, location } of default_nav_buttons_definitions) { this.controls.createBankControl( From 173caa6746a8d6c733dccb0f01300b365df8003f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 16 Nov 2023 01:00:07 +0000 Subject: [PATCH 30/53] fix: elgato plugin --- lib/Service/ElgatoPlugin.js | 171 ++++++++++++++++++++++++--------- lib/Surface/Controller.js | 5 +- lib/Surface/IP/ElgatoPlugin.js | 61 +----------- 3 files changed, 132 insertions(+), 105 deletions(-) diff --git a/lib/Service/ElgatoPlugin.js b/lib/Service/ElgatoPlugin.js index 3581ab7d6b..d2c8583cca 100644 --- a/lib/Service/ElgatoPlugin.js +++ b/lib/Service/ElgatoPlugin.js @@ -1,7 +1,11 @@ +import LogController from '../Log/Controller.js' import ServiceBase from './Base.js' import { WebSocketServer, WebSocket } from 'ws' import { oldBankIndexToXY } from '../Shared/ControlId.js' import { EventEmitter } from 'events' +import ImageWriteQueue from '../Resources/ImageWriteQueue.js' +import imageRs from '@julusian/image-rs' +import { translateRotation } from '../Resources/Util.js' /** * Class providing the Elgato Plugin service. @@ -74,13 +78,17 @@ class ServiceElgatoPlugin extends ServiceBase { location.pageNumber = Number(location.pageNumber) if (this.client && this.client.buttonListeners) { - if (this.client.buttonListeners.has(`${location.pageNumber}_${location.column}_${location.row}`)) { - this.client.apicommand('fillImage', { - page: location.pageNumber, - column: location.column, - row: location.row, - data: render.buffer, - }) + const id = `${location.pageNumber}_${location.column}_${location.row}` + if (this.client.buttonListeners.has(id)) { + this.client.fillImage( + id, + { + page: location.pageNumber, + column: location.column, + row: location.row, + }, + render + ) } // Backwards compatible mode @@ -88,13 +96,17 @@ class ServiceElgatoPlugin extends ServiceBase { const rows = 4 if (location.column >= 0 && location.row >= 0 && location.column < cols && location.row < rows) { const bank = location.column + location.row * cols - if (this.client.buttonListeners.has(`${location.pageNumber}_${bank}`)) { - this.client.apicommand('fillImage', { - page: location.pageNumber, - bank: bank, - keyIndex: bank, - data: render.buffer, - }) + const id = `${location.pageNumber}_${bank}` + if (this.client.buttonListeners.has(id)) { + this.client.fillImage( + id, + { + page: location.pageNumber, + bank: bank, + keyIndex: bank, + }, + render + ) } } } @@ -106,38 +118,37 @@ class ServiceElgatoPlugin extends ServiceBase { */ #initAPI2(socket) { this.logger.silly('init api v2') - socket.once( - 'new_device', - (/** @type {string | import('../Surface/IP/ElgatoPlugin.js').ElgatoPluginClientInfo} */ info) => { - try { - // Process the parameter, backwards compatible - const remoteId = typeof info === 'string' ? info : info.id - const clientInfo = typeof info === 'string' ? { id: remoteId } : info + socket.once('new_device', (/** @type {string | Record} */ info) => { + try { + // Process the parameter, backwards compatible + const remoteId = typeof info === 'string' ? info : info.id + const clientInfo = typeof info === 'string' ? { id: remoteId } : info - this.logger.silly('add device: ' + socket.remoteAddress, remoteId) + this.logger.silly('add device: ' + socket.remoteAddress, remoteId) - // Use ip right now, since the pluginUUID is new on each boot and makes Companion - // forget all settings for the device. (page and orientation) - const id = 'elgato_plugin-' + socket.remoteAddress + // Use ip right now, since the pluginUUID is new on each boot and makes Companion + // forget all settings for the device. (page and orientation) + const id = 'elgato_plugin-' + socket.remoteAddress - this.surfaces.addElgatoPluginDevice(id, socket, clientInfo) + socket.supportsPng = !!clientInfo.supportsPng - socket.apireply('new_device', { result: true }) + this.surfaces.addElgatoPluginDevice(id, socket) - this.client = socket + socket.apireply('new_device', { result: true }) - socket.on('close', () => { - this.surfaces.removeDevice(id) - socket.removeAllListeners('keyup') - socket.removeAllListeners('keydown') - delete this.client - }) - } catch (/** @type {any} */ e) { - this.logger.error(`Elgato plugin add failed: ${e?.message ?? e}`) - socket.close() - } + this.client = socket + + socket.on('close', () => { + this.surfaces.removeDevice(id) + socket.removeAllListeners('keyup') + socket.removeAllListeners('keydown') + delete this.client + }) + } catch (/** @type {any} */ e) { + this.logger.error(`Elgato plugin add failed: ${e?.message ?? e}`) + socket.close() } - ) + }) socket.on('request_button', (args) => { this.logger.silly('request_button: ', args) @@ -148,9 +159,9 @@ class ServiceElgatoPlugin extends ServiceBase { socket.apireply('request_button', { result: 'ok' }) const location = { - pageNumber: args.page, - column: args.column, - row: args.row, + pageNumber: Number(args.page), + column: Number(args.column), + row: Number(args.row), } this.#handleButtonDrawn(location, this.graphics.getCachedRenderOrGeneratePlaceholder(location)) @@ -159,10 +170,10 @@ class ServiceElgatoPlugin extends ServiceBase { socket.apireply('request_button', { result: 'ok' }) - const xy = oldBankIndexToXY(parseInt(args.bank) + 1) + const xy = oldBankIndexToXY(Number(args.bank) + 1) if (xy) { const location = { - pageNumber: args.page, + pageNumber: Number(args.page), column: xy[0], row: xy[1], } @@ -250,10 +261,10 @@ class ServiceElgatoPlugin extends ServiceBase { socket.on('message', (message) => { try { let data = JSON.parse(message.toString()) - socket.emit(data.command, data.arguments) + wrappedSocket.emit(data.command, data.arguments) //this.logger.silly('emitting command ' + data.command); } catch (e) { - this.logger.silly('protocol error:', e) + this.logger.warn('protocol error:', e) } }) @@ -266,6 +277,8 @@ class ServiceElgatoPlugin extends ServiceBase { export default ServiceElgatoPlugin export class ServiceElgatoPluginSocket extends EventEmitter { + #logger = LogController.createLogger('Surface/ElgatoPlugin/Socket') + /** * @type {WebSocket} * @readonly @@ -284,6 +297,25 @@ export class ServiceElgatoPluginSocket extends EventEmitter { */ buttonListeners = new Set() + /** + * + * @type {boolean} + * @access public + */ + supportsPng = false + + /** + * @type {import('../Surface/Util.js').SurfaceRotation | 90 | -90 | 180 | 0 | null} + * @access public + */ + rotation = 0 + + /** + * @type {ImageWriteQueue} + * @access private + */ + #write_queue + /** * @param {WebSocket} socket * @param {string }remoteAddress @@ -293,6 +325,53 @@ export class ServiceElgatoPluginSocket extends EventEmitter { this.socket = socket this.remoteAddress = remoteAddress + + this.#write_queue = new ImageWriteQueue( + this.#logger, + async ( + /** @type {string | number} */ _id, + /** @type {Record} */ partial, + /** @type {Buffer} */ buffer + ) => { + const targetSize = 72 // Compatibility + try { + const imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square + let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( + targetSize, + targetSize + ) + + const rotation = translateRotation(this.rotation) + if (rotation !== null) image = image.rotate(rotation) + + const newbuffer = await image.toBuffer(imageRs.PixelFormat.Rgb) + + this.apicommand('fillImage', { ...partial, data: newbuffer }) + } catch (/** @type {any} */ e) { + this.#logger.debug(`scale image failed: ${e}\n${e.stack}`) + this.emit('remove') + return + } + } + ) + } + + /** + * + * @param {string | number} id + * @param {Record} partial + * @param {import('../Graphics/ImageResult.js').ImageResult} render + */ + fillImage(id, partial, render) { + if (this.supportsPng) { + this.apicommand('fillImage', { + ...partial, + png: true, + data: render.asDataUrl, + }) + } else { + this.#write_queue.queue(id, partial, render.buffer) + } } /** diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index 618857ef1b..c97230c503 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -839,13 +839,12 @@ class SurfaceController extends CoreBase { * Add the elgato plugin connection * @param {string} devicePath * @param {import('../Service/ElgatoPlugin.js').ServiceElgatoPluginSocket} socket - * @param {import('./IP/ElgatoPlugin.js').ElgatoPluginClientInfo} clientInfo * @returns */ - addElgatoPluginDevice(devicePath, socket, clientInfo) { + addElgatoPluginDevice(devicePath, socket) { this.removeDevice(devicePath) - const device = new SurfaceIPElgatoPlugin(this.registry, devicePath, socket, clientInfo) + const device = new SurfaceIPElgatoPlugin(this.registry, devicePath, socket) this.#createSurfaceHandler(devicePath, 'elgato-plugin', device) diff --git a/lib/Surface/IP/ElgatoPlugin.js b/lib/Surface/IP/ElgatoPlugin.js index 559a487f0a..fa26190fa9 100644 --- a/lib/Surface/IP/ElgatoPlugin.js +++ b/lib/Surface/IP/ElgatoPlugin.js @@ -17,30 +17,13 @@ import LogController from '../../Log/Controller.js' import { EventEmitter } from 'events' -import ImageWriteQueue from '../../Resources/ImageWriteQueue.js' -import imageRs from '@julusian/image-rs' -import { translateRotation } from '../../Resources/Util.js' import { oldBankIndexToXY, xyToOldBankIndex } from '../../Shared/ControlId.js' import { convertPanelIndexToXY } from '../Util.js' import { LEGACY_MAX_BUTTONS } from '../../Util/Constants.js' -/** - * @typedef {{ - * id: string - * supportsPng?: boolean - * }} ElgatoPluginClientInfo - */ - class SurfaceIPElgatoPlugin extends EventEmitter { #logger = LogController.createLogger('Surface/IP/ElgatoPlugin') - /** - * Whether the plugin is new enough to support pngs - * @type {boolean} - * @access private - */ - #supportsPng = false - /** * @type {import('../Util.js').GridSize} * @readonly @@ -63,9 +46,8 @@ class SurfaceIPElgatoPlugin extends EventEmitter { * @param {import('../../Registry.js').default} registry * @param {string} devicePath * @param {import('../../Service/ElgatoPlugin.js').ServiceElgatoPluginSocket} socket - * @param {ElgatoPluginClientInfo} clientInfo */ - constructor(registry, devicePath, socket, clientInfo) { + constructor(registry, devicePath, socket) { super() this.controls = registry.controls @@ -73,8 +55,7 @@ class SurfaceIPElgatoPlugin extends EventEmitter { this.socket = socket - if (clientInfo?.supportsPng) this.#supportsPng = true - this.#logger.debug(`Adding Elgato Streamdeck Plugin (${this.#supportsPng ? 'PNG' : 'Bitmap'})`) + this.#logger.debug(`Adding Elgato Streamdeck Plugin (${this.socket.supportsPng ? 'PNG' : 'Bitmap'})`) this.info = { type: 'Elgato Streamdeck Plugin', @@ -159,31 +140,6 @@ class SurfaceIPElgatoPlugin extends EventEmitter { } } }) - - this.write_queue = new ImageWriteQueue( - this.#logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer) => { - const targetSize = 72 // Compatibility - try { - const imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square - let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( - targetSize, - targetSize - ) - - const rotation = translateRotation(this._config.rotation) - if (rotation !== null) image = image.rotate(rotation) - - const newbuffer = await image.toBuffer(imageRs.PixelFormat.Rgb) - - this.socket.apicommand('fillImage', { keyIndex: key, data: newbuffer }) - } catch (/** @type {any} */ e) { - this.#logger.debug(`scale image failed: ${e}\n${e.stack}`) - this.emit('remove') - return - } - } - ) } /** @@ -212,7 +168,6 @@ class SurfaceIPElgatoPlugin extends EventEmitter { * @returns {void} */ draw(x, y, render) { - // if (buffer === undefined || buffer.length != 15552) { if (render.buffer === undefined || render.buffer.length === 0) { this.#logger.silly('buffer was not 15552, but ', render.buffer?.length) return @@ -220,15 +175,7 @@ class SurfaceIPElgatoPlugin extends EventEmitter { const key = xyToOldBankIndex(x, y) if (key) { - if (this.#supportsPng) { - this.socket.apicommand('fillImage', { - keyIndex: key, - png: true, - data: render.asDataUrl, - }) - } else { - this.write_queue.queue(key, render.buffer) - } + this.socket.fillImage(key, { keyIndex: key - 1 }, render) } } @@ -249,6 +196,8 @@ class SurfaceIPElgatoPlugin extends EventEmitter { */ setConfig(config, _force) { this._config = config + + this.socket.rotation = this._config.rotation } } From a5feec2985a87d4e0169fbb0a06274d6609e7721 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Thu, 16 Nov 2023 01:20:10 +0000 Subject: [PATCH 31/53] chore: update bundled-modules d0eca6a update novastar-controller to v2.0.4 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index 7a400a6732..d0eca6a512 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 7a400a6732625503e851820561de266fe06fc99a +Subproject commit d0eca6a512c77899af6249eb683fbb39fe62d9f2 From 4fefd2a2ae67486a9cadf340ddaf6ecd4640a757 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 16 Nov 2023 22:33:52 +0000 Subject: [PATCH 32/53] fix: page buttons missing from page 99 --- lib/Data/ImportExport.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 64023536cd..d601dcd810 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -507,8 +507,7 @@ class DataImportExport extends CoreBase { * @returns {void} */ createInitialPageButtons(pageCount) { - for (let page = 0; page < pageCount; page++) { - // TODO - is this off by one? + for (let page = 1; page <= pageCount; page++) { for (const definition of default_nav_buttons_definitions) { const location = { ...definition.location, From ef47aec479b2eaefb914644f423393dcd464f151 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Fri, 17 Nov 2023 06:24:05 +0000 Subject: [PATCH 33/53] chore: update bundled-modules b2ae9c4 update novastar-controller to v2.1.0-test1 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index d0eca6a512..b2ae9c4562 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit d0eca6a512c77899af6249eb683fbb39fe62d9f2 +Subproject commit b2ae9c4562b0d8d6a2992fc03577e2de1d64209b From fe5528d63c871fba0e6f6ea7259afd41f5e44313 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 13:42:38 +0000 Subject: [PATCH 34/53] fix: align streamdeck plus lcd strip drawing #2652 --- lib/Surface/USB/ElgatoStreamDeck.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Surface/USB/ElgatoStreamDeck.js b/lib/Surface/USB/ElgatoStreamDeck.js index 0d94704874..de76288ed8 100644 --- a/lib/Surface/USB/ElgatoStreamDeck.js +++ b/lib/Surface/USB/ElgatoStreamDeck.js @@ -192,7 +192,7 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { const maxAttempts = 3 for (let attempts = 1; attempts <= maxAttempts; attempts++) { try { - const x = key * 200 + 50 + const x = key * 216.666 + 25 await this.#streamDeck.fillLcdRegion(x, 0, newbuffer, { format: 'rgb', width: 100, From 4bd44c218c1e765681ff53680f06e19b44fca5f4 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 13:46:36 +0000 Subject: [PATCH 35/53] fix: Action Recorder not saving recorded Actions #2653 --- lib/Controls/Fragments/FragmentActions.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/Controls/Fragments/FragmentActions.js b/lib/Controls/Fragments/FragmentActions.js index 30cb7bd6ab..9ad2bcf6a4 100644 --- a/lib/Controls/Fragments/FragmentActions.js +++ b/lib/Controls/Fragments/FragmentActions.js @@ -290,17 +290,20 @@ export default class FragmentActions extends CoreBase { * @access public */ actionReplaceAll(setId, newActions) { - const action_set = this.action_sets[setId] - if (action_set) { + const oldActionSet = this.action_sets[setId] + if (oldActionSet) { // Remove the old actions - for (const action of action_set) { + for (const action of oldActionSet) { this.cleanupAction(action) } - this.action_sets[setId] = [] + + /** @type {ActionInstance[]} */ + const newActionSet = [] + this.action_sets[setId] = newActionSet // Add new actions for (const action of newActions) { - action_set.push(action) + newActionSet.push(action) this.#actionSubscribe(action) } From 7783fa957d30a16bf7e22acd8d14e880f2bedf08 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 15:24:33 +0000 Subject: [PATCH 36/53] chore: add width and height to `ImageResult` --- lib/Graphics/Controller.js | 11 +++++++---- lib/Graphics/ImageResult.js | 22 +++++++++++++++++++++- lib/Graphics/Renderer.js | 20 ++++++++++++-------- lib/Service/ElgatoPlugin.js | 15 ++++++++------- lib/Surface/IP/Satellite.js | 17 +++++++++-------- lib/Surface/USB/ElgatoStreamDeck.js | 29 ++++++++++++++--------------- lib/Surface/USB/Infinitton.js | 5 ++--- lib/Surface/USB/LoupedeckCt.js | 13 +++++++------ lib/Surface/USB/LoupedeckLive.js | 15 ++++++++------- 9 files changed, 88 insertions(+), 59 deletions(-) diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index 6ab024be74..a765677149 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -145,13 +145,13 @@ class GraphicsController extends CoreBase { render = this.#renderLRUCache.get(key) if (!render) { - const { buffer, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ + const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ this.#drawOptions, buttonStyle, location, pagename, ]) - render = GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, buttonStyle) + render = GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, buttonStyle) } } else { render = GraphicsRenderer.drawBlank(this.#drawOptions, location) @@ -240,8 +240,11 @@ class GraphicsController extends CoreBase { size: buttonStyle.size === 'auto' ? 'auto' : Number(buttonStyle.size), } - const { buffer, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [this.#drawOptions, drawStyle]) - return GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) + const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ + this.#drawOptions, + drawStyle, + ]) + return GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, drawStyle) } /** diff --git a/lib/Graphics/ImageResult.js b/lib/Graphics/ImageResult.js index 3f191c999d..292a9f10a1 100644 --- a/lib/Graphics/ImageResult.js +++ b/lib/Graphics/ImageResult.js @@ -18,6 +18,22 @@ export class ImageResult { */ buffer + /** + * Image pixel buffer width + * @type {number} + * @access public + * @readonly + */ + bufferWidth + + /** + * Image pixel buffer height + * @type {number} + * @access public + * @readonly + */ + bufferHeight + /** * Image draw style * @type {ImageResultStyle | undefined} @@ -28,11 +44,15 @@ export class ImageResult { /** * @param {Buffer} buffer + * @param {number} width + * @param {number} height * @param {string} dataUrl * @param {ImageResultStyle | undefined} style */ - constructor(buffer, dataUrl, style) { + constructor(buffer, width, height, dataUrl, style) { this.buffer = buffer + this.bufferWidth = width + this.bufferHeight = height this.#dataUrl = dataUrl this.style = style diff --git a/lib/Graphics/Renderer.js b/lib/Graphics/Renderer.js index 11c53a5092..efbfb495bb 100644 --- a/lib/Graphics/Renderer.js +++ b/lib/Graphics/Renderer.js @@ -62,7 +62,7 @@ export default class GraphicsRenderer { img.horizontalLine(13.5, 'rgb(30, 30, 30)') } // console.timeEnd('drawBlankImage') - return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) + return new ImageResult(img.buffer(), img.realwidth, img.realheight, img.toDataURLSync(), undefined) } /** @@ -75,28 +75,30 @@ export default class GraphicsRenderer { * @returns {Promise} Image render object */ static async drawButtonImage(options, drawStyle, location, pagename) { - const { buffer, dataUrl, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( + const { buffer, width, height, dataUrl, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( options, drawStyle, location, pagename ) - return GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) + return GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, drawStyle) } /** * * @param {Buffer} buffer + * @param {number} width + * @param {number} height * @param {string} dataUrl * @param {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle * @returns */ - static wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) { + static wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, drawStyle) { const draw_style2 = draw_style === 'button' ? (drawStyle.style === 'button' ? drawStyle : undefined) : draw_style - return new ImageResult(buffer, dataUrl, draw_style2) + return new ImageResult(buffer, width, height, dataUrl, draw_style2) } /** @@ -106,7 +108,7 @@ export default class GraphicsRenderer { * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public - * @returns {Promise<{ buffer: Buffer, dataUrl: string, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object + * @returns {Promise<{ buffer: Buffer, width: number, height: number, dataUrl: string, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object */ static async drawButtonImageUnwrapped(options, drawStyle, location, pagename) { // console.log('starting drawBankImage '+ performance.now()) @@ -181,6 +183,8 @@ export default class GraphicsRenderer { // console.timeEnd('drawBankImage') return { buffer: img.buffer(), + width: img.realwidth, + height: img.realheight, dataUrl: await img.toDataURL(), draw_style, } @@ -372,7 +376,7 @@ export default class GraphicsRenderer { const img = new Image(72, 72, 3) img.fillColor(colorDarkGrey) img.drawTextLineAligned(36, 36, `${num}`, colorWhite, 44, 'center', 'center') - return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) + return new ImageResult(img.buffer(), img.realwidth, img.realheight, img.toDataURLSync(), undefined) } /** @@ -388,6 +392,6 @@ export default class GraphicsRenderer { img.drawAlignedText(0, 15, 72, 72, code.replace(/[a-z0-9]/gi, '*'), colorWhite, 18, 'center', 'center') } - return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) + return new ImageResult(img.buffer(), img.realwidth, img.realheight, img.toDataURLSync(), undefined) } } diff --git a/lib/Service/ElgatoPlugin.js b/lib/Service/ElgatoPlugin.js index d2c8583cca..ff141066dd 100644 --- a/lib/Service/ElgatoPlugin.js +++ b/lib/Service/ElgatoPlugin.js @@ -331,15 +331,16 @@ export class ServiceElgatoPluginSocket extends EventEmitter { async ( /** @type {string | number} */ _id, /** @type {Record} */ partial, - /** @type {Buffer} */ buffer + /** @type {import('../Graphics/ImageResult.js').ImageResult} */ render ) => { const targetSize = 72 // Compatibility try { - const imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square - let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( - targetSize, - targetSize - ) + let image = imageRs.ImageTransformer.fromBuffer( + render.buffer, + render.bufferWidth, + render.bufferHeight, + imageRs.PixelFormat.Rgba + ).scale(targetSize, targetSize) const rotation = translateRotation(this.rotation) if (rotation !== null) image = image.rotate(rotation) @@ -370,7 +371,7 @@ export class ServiceElgatoPluginSocket extends EventEmitter { data: render.asDataUrl, }) } else { - this.#write_queue.queue(id, partial, render.buffer) + this.#write_queue.queue(id, partial, render) } } diff --git a/lib/Surface/IP/Satellite.js b/lib/Surface/IP/Satellite.js index 5890e9e804..ff44b29180 100644 --- a/lib/Surface/IP/Satellite.js +++ b/lib/Surface/IP/Satellite.js @@ -114,23 +114,24 @@ class SurfaceIPSatellite extends EventEmitter { this.#writeQueue = new ImageWriteQueue( this.#logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer, /** @type {*} */ style) => { + async (/** @type {number} */ key, /** @type {import('../../Graphics/ImageResult.js').ImageResult} */ render) => { const targetSize = this.#streamBitmapSize if (!targetSize) return try { - const imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square - let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( - targetSize, - targetSize - ) + let image = imageRs.ImageTransformer.fromBuffer( + render.buffer, + render.bufferWidth, + render.bufferHeight, + imageRs.PixelFormat.Rgba + ).scale(targetSize, targetSize) const rotation = translateRotation(this.#config.rotation) if (rotation !== null) image = image.rotate(rotation) const newbuffer = await image.toBuffer(imageRs.PixelFormat.Rgb) - this.#sendDraw(key, newbuffer, style) + this.#sendDraw(key, newbuffer, render.style) } catch (/** @type {any} */ e) { this.#logger.debug(`scale image failed: ${e}\n${e.stack}`) this.emit('remove') @@ -202,7 +203,7 @@ class SurfaceIPSatellite extends EventEmitter { if (this.#streamBitmapSize) { // Images need scaling - this.#writeQueue.queue(key, render.buffer, render.style) + this.#writeQueue.queue(key, render) } else { this.#sendDraw(key, undefined, render.style) } diff --git a/lib/Surface/USB/ElgatoStreamDeck.js b/lib/Surface/USB/ElgatoStreamDeck.js index de76288ed8..fcf23ffcd2 100644 --- a/lib/Surface/USB/ElgatoStreamDeck.js +++ b/lib/Surface/USB/ElgatoStreamDeck.js @@ -82,18 +82,17 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { this.write_queue = new ImageWriteQueue( this.#logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer) => { - let newbuffer = buffer + async (/** @type {number} */ key, /** @type {import('../../Graphics/ImageResult.js').ImageResult} */ render) => { + let newbuffer = render.buffer const targetSize = this.#streamDeck.ICON_SIZE if (targetSize === 0) { return } else { try { - const imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square let image = imageRs.ImageTransformer.fromBuffer( - buffer, - imagesize, - imagesize, + render.buffer, + render.bufferWidth, + render.bufferHeight, imageRs.PixelFormat.Rgba ).scale(targetSize, targetSize) @@ -166,16 +165,16 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { this.lcdWriteQueue = new ImageWriteQueue( this.#logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer) => { - // const rotation = translateRotation(this.config.rotation) - + async ( + /** @type {number} */ key, + /** @type {import('../../Graphics/ImageResult.js').ImageResult} */ render + ) => { let newbuffer try { - let imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square let image = imageRs.ImageTransformer.fromBuffer( - buffer, - imagesize, - imagesize, + render.buffer, + render.bufferWidth, + render.bufferHeight, imageRs.PixelFormat.Rgba ).scale(100, 100) @@ -322,12 +321,12 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { if (key === null) return if (key >= 0 && key < this.#streamDeck.NUM_KEYS) { - this.write_queue.queue(key, render.buffer) + this.write_queue.queue(key, render) } const segmentIndex = key - this.#streamDeck.NUM_KEYS if (this.lcdWriteQueue && segmentIndex >= 0 && segmentIndex < this.#streamDeck.KEY_COLUMNS) { - this.lcdWriteQueue.queue(segmentIndex, render.buffer) + this.lcdWriteQueue.queue(segmentIndex, render) } } } diff --git a/lib/Surface/USB/Infinitton.js b/lib/Surface/USB/Infinitton.js index 84e7d45a03..223986d833 100644 --- a/lib/Surface/USB/Infinitton.js +++ b/lib/Surface/USB/Infinitton.js @@ -174,15 +174,14 @@ class SurfaceUSBInfinitton extends EventEmitter { key = this.#mapButton(key) if (key >= 0 && !isNaN(key)) { - const imagesize = Math.sqrt(render.buffer.length / 4) // TODO: assuming here that the image is square const targetSize = 72 const rotation = translateRotation(this.config.rotation) try { let image = imageRs.ImageTransformer.fromBuffer( render.buffer, - imagesize, - imagesize, + render.bufferWidth, + render.bufferHeight, imageRs.PixelFormat.Rgba ).scale(targetSize, targetSize) diff --git a/lib/Surface/USB/LoupedeckCt.js b/lib/Surface/USB/LoupedeckCt.js index 67136dbf75..efeaef97ee 100644 --- a/lib/Surface/USB/LoupedeckCt.js +++ b/lib/Surface/USB/LoupedeckCt.js @@ -268,7 +268,7 @@ class SurfaceUSBLoupedeckCt extends EventEmitter { this.#writeQueue = new ImageWriteQueue( this.logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer) => { + async (/** @type {number} */ key, /** @type {import('../../Graphics/ImageResult.js').ImageResult} */ render) => { let width = this.#loupedeck.lcdKeySize let height = this.#loupedeck.lcdKeySize @@ -281,11 +281,12 @@ class SurfaceUSBLoupedeckCt extends EventEmitter { let newbuffer try { - let imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square - let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( - width, - height - ) + let image = imageRs.ImageTransformer.fromBuffer( + render.buffer, + render.bufferWidth, + render.bufferHeight, + imageRs.PixelFormat.Rgba + ).scale(width, height) const rotation = translateRotation(this.config.rotation) if (rotation !== null) image = image.rotate(rotation) diff --git a/lib/Surface/USB/LoupedeckLive.js b/lib/Surface/USB/LoupedeckLive.js index 79823f09be..0394bf5769 100644 --- a/lib/Surface/USB/LoupedeckLive.js +++ b/lib/Surface/USB/LoupedeckLive.js @@ -277,17 +277,18 @@ class SurfaceUSBLoupedeckLive extends EventEmitter { this.#writeQueue = new ImageWriteQueue( this.logger, - async (/** @type {number} */ key, /** @type {Buffer} */ buffer) => { + async (/** @type {number} */ key, /** @type {import('../../Graphics/ImageResult.js').ImageResult} */ render) => { const width = this.#loupedeck.lcdKeySize const height = this.#loupedeck.lcdKeySize let newbuffer try { - let imagesize = Math.sqrt(buffer.length / 4) // TODO: assuming here that the image is square - let image = imageRs.ImageTransformer.fromBuffer(buffer, imagesize, imagesize, imageRs.PixelFormat.Rgba).scale( - width, - height - ) + let image = imageRs.ImageTransformer.fromBuffer( + render.buffer, + render.bufferWidth, + render.bufferHeight, + imageRs.PixelFormat.Rgba + ).scale(width, height) const rotation = translateRotation(this.config.rotation) if (rotation !== null) image = image.rotate(rotation) @@ -403,7 +404,7 @@ class SurfaceUSBLoupedeckLive extends EventEmitter { if (lcdX >= 0 && lcdX < this.#modelInfo.lcdCols && y >= 0 && y < this.#modelInfo.lcdRows) { const button = lcdX + y * this.#modelInfo.lcdCols - this.#writeQueue.queue(button, render.buffer) + this.#writeQueue.queue(button, render) } const buttonIndex = this.#modelInfo.buttons.findIndex((btn) => btn[0] == x && btn[1] == y) From 7d82ba49faf6e0e66f74ee6b7325fb6494d8eabf Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 17:00:42 +0000 Subject: [PATCH 37/53] fix: loupedeck surface type naming --- lib/Surface/USB/LoupedeckCt.js | 2 +- lib/Surface/USB/LoupedeckLive.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Surface/USB/LoupedeckCt.js b/lib/Surface/USB/LoupedeckCt.js index efeaef97ee..7a527876e6 100644 --- a/lib/Surface/USB/LoupedeckCt.js +++ b/lib/Surface/USB/LoupedeckCt.js @@ -334,7 +334,7 @@ class SurfaceUSBLoupedeckCt extends EventEmitter { } async #init() { - this.logger.debug(`Loupedeck ${this.#loupedeck.modelName} detected`) + this.logger.debug(`${this.#loupedeck.modelName} detected`) // Make sure the first clear happens properly await this.#loupedeck.blankDevice(true, true) diff --git a/lib/Surface/USB/LoupedeckLive.js b/lib/Surface/USB/LoupedeckLive.js index 0394bf5769..2c2896e782 100644 --- a/lib/Surface/USB/LoupedeckLive.js +++ b/lib/Surface/USB/LoupedeckLive.js @@ -203,7 +203,7 @@ class SurfaceUSBLoupedeckLive extends EventEmitter { this.logger.debug(`Adding Loupedeck Live USB device ${devicePath}`) this.info = { - type: `Loupedeck ${this.#loupedeck.modelName}`, + type: this.#loupedeck.modelName, devicePath: devicePath, configFields: ['brightness'], deviceId: `loupedeck:${serialNumber}`, From c1764b135080983b4177656b10bc4e8fb2324d89 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 17:17:41 +0000 Subject: [PATCH 38/53] chore: refactoring --- lib/Controls/Controller.js | 4 ++-- lib/Data/ImportExport.js | 8 ++++---- lib/Graphics/Controller.js | 6 +++--- lib/Graphics/Renderer.js | 6 +++--- lib/Graphics/Thread.js | 2 +- webui/src/App.scss | 2 +- webui/src/Buttons/ActionRecorder.jsx | 2 +- webui/src/Components/ButtonPreview.jsx | 4 ++-- webui/src/Controls/ActionSetEditor.jsx | 2 +- webui/src/Controls/FeedbackEditor.jsx | 2 +- webui/src/Emulator/Emulator.jsx | 4 ++-- webui/src/ImportExport/Import/Page.jsx | 4 ++-- webui/src/TabletView/index.jsx | 2 +- webui/src/scss/{_bank.scss => _button-control.scss} | 8 ++++---- webui/src/scss/_button-edit.scss | 4 ++-- webui/src/scss/_button-grid.scss | 10 +++++----- webui/src/scss/_tablet.scss | 8 ++++---- 17 files changed, 39 insertions(+), 39 deletions(-) rename webui/src/scss/{_bank.scss => _button-control.scss} (94%) diff --git a/lib/Controls/Controller.js b/lib/Controls/Controller.js index 3a2c6931a6..91e421156d 100644 --- a/lib/Controls/Controller.js +++ b/lib/Controls/Controller.js @@ -188,7 +188,7 @@ class ControlsController extends CoreBase { } if (type) { - this.createBankControl(location, type) + this.createButtonControl(location, type) } } ) @@ -1485,7 +1485,7 @@ class ControlsController extends CoreBase { * @returns {string | null} controlId * @access public */ - createBankControl(location, newType) { + createButtonControl(location, newType) { if (!this.page.isPageValid(location.pageNumber)) return null const controlId = CreateBankControlId(nanoid()) diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index d601dcd810..0bdd0c23b7 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -516,7 +516,7 @@ class DataImportExport extends CoreBase { const oldControlId = this.page.getControlIdAt(location) if (oldControlId) this.controls.deleteControl(oldControlId) - this.controls.createBankControl(location, definition.type) + this.controls.createButtonControl(location, definition.type) } } } @@ -708,7 +708,7 @@ class DataImportExport extends CoreBase { this.page.resetPage(pageNumber) for (const { type, location } of default_nav_buttons_definitions) { - this.controls.createBankControl( + this.controls.createButtonControl( { pageNumber, ...location, @@ -730,7 +730,7 @@ class DataImportExport extends CoreBase { (pageNumber) => { // make magical page buttons! for (const { type, location } of default_nav_buttons_definitions) { - this.controls.createBankControl( + this.controls.createButtonControl( { pageNumber, ...location, @@ -987,7 +987,7 @@ class DataImportExport extends CoreBase { if (!skipNavButtons) { for (const { type, location } of default_nav_buttons_definitions) { - this.controls.createBankControl( + this.controls.createButtonControl( { pageNumber, ...location, diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index a765677149..83004368a5 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -145,7 +145,7 @@ class GraphicsController extends CoreBase { render = this.#renderLRUCache.get(key) if (!render) { - const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ + const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawButtonImage', [ this.#drawOptions, buttonStyle, location, @@ -178,7 +178,7 @@ class GraphicsController extends CoreBase { this.emit('button_drawn', location, render) } } catch (e) { - this.logger.warn(`drawBankImage failed: ${e}`) + this.logger.warn(`drawButtonImage failed: ${e}`) } }, 5 @@ -240,7 +240,7 @@ class GraphicsController extends CoreBase { size: buttonStyle.size === 'auto' ? 'auto' : Number(buttonStyle.size), } - const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ + const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawButtonImage', [ this.#drawOptions, drawStyle, ]) diff --git a/lib/Graphics/Renderer.js b/lib/Graphics/Renderer.js index efbfb495bb..353c42aad9 100644 --- a/lib/Graphics/Renderer.js +++ b/lib/Graphics/Renderer.js @@ -111,8 +111,8 @@ export default class GraphicsRenderer { * @returns {Promise<{ buffer: Buffer, width: number, height: number, dataUrl: string, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object */ static async drawButtonImageUnwrapped(options, drawStyle, location, pagename) { - // console.log('starting drawBankImage '+ performance.now()) - // console.time('drawBankImage') + // console.log('starting drawButtonImage '+ performance.now()) + // console.time('drawButtonImage') const img = new Image(72, 72, 4) /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} */ @@ -180,7 +180,7 @@ export default class GraphicsRenderer { await GraphicsRenderer.#drawButtonMain(img, options, drawStyle, location) } - // console.timeEnd('drawBankImage') + // console.timeEnd('drawButtonImage') return { buffer: img.buffer(), width: img.realwidth, diff --git a/lib/Graphics/Thread.js b/lib/Graphics/Thread.js index 342a439708..02b21a2c07 100644 --- a/lib/Graphics/Thread.js +++ b/lib/Graphics/Thread.js @@ -19,5 +19,5 @@ import GraphicsRenderer from './Renderer.js' import workerPool from 'workerpool' workerPool.worker({ - drawBankImage: GraphicsRenderer.drawButtonImageUnwrapped, + drawButtonImage: GraphicsRenderer.drawButtonImageUnwrapped, }) diff --git a/webui/src/App.scss b/webui/src/App.scss index 75c51c7d46..c47d6d695b 100644 --- a/webui/src/App.scss +++ b/webui/src/App.scss @@ -9,7 +9,7 @@ @import 'scss/tablet'; @import 'scss/action-recorder'; -@import 'scss/bank'; +@import 'scss/button-control'; @import 'scss/button-edit'; @import 'scss/button-grid'; @import 'scss/controls'; diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.jsx index 246a67f938..b87728eb33 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.jsx @@ -397,7 +397,7 @@ function ButtonPicker({ selectButton }) { -
+
{hasBeenInView && (
{actionSpec?.description || ''}
{location && showButtonPreview && ( -
+
)} diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.jsx index 6870c564ec..c496723cea 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.jsx @@ -447,7 +447,7 @@ function FeedbackEditor({
{feedbackSpec?.description || ''}
{location && showButtonPreview && ( -
+
)} diff --git a/webui/src/Emulator/Emulator.jsx b/webui/src/Emulator/Emulator.jsx index 4d5a533286..4cceb8048e 100644 --- a/webui/src/Emulator/Emulator.jsx +++ b/webui/src/Emulator/Emulator.jsx @@ -271,13 +271,13 @@ function CyclePages({ imageCache, setKeyDown, columns, rows }) { )} */} {/* */}
-
+
{' '} {Array(rows) .fill(0) .map((_, y) => { return ( - + {Array(columns) .fill(0) .map((_2, x) => { diff --git a/webui/src/ImportExport/Import/Page.jsx b/webui/src/ImportExport/Import/Page.jsx index 94a186f024..81402f5d99 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.jsx @@ -86,7 +86,7 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do setPage={isSinglePage ? null : setImportPageNumber} /> -
+
{hasBeenRendered && ( -
+
{hasBeenRendered && ( - +
{validPages.map((number, i) => ( diff --git a/webui/src/scss/_bank.scss b/webui/src/scss/_button-control.scss similarity index 94% rename from webui/src/scss/_bank.scss rename to webui/src/scss/_button-control.scss index 56054845ae..2e074d2911 100644 --- a/webui/src/scss/_bank.scss +++ b/webui/src/scss/_button-control.scss @@ -1,4 +1,4 @@ -.bank { +.button-control { -webkit-touch-callout: none !important; -webkit-user-select: none !important; user-select: none !important; @@ -13,12 +13,12 @@ cursor: pointer; } - &.selected .bank-border { + &.selected .button-border { border: 1px solid #d50215 !important; box-shadow: 0px 0px 0px 1px #d50215 inset; } - &.fixed .bank-border { + &.fixed .button-border { width: 72px; height: 72px; } @@ -37,7 +37,7 @@ // -webkit-user-drag: inherit; // } - .bank-border { + .button-border { position: relative; object-fit: contain; display: block; diff --git a/webui/src/scss/_button-edit.scss b/webui/src/scss/_button-edit.scss index aa4418c68c..ff7c1838f9 100644 --- a/webui/src/scss/_button-edit.scss +++ b/webui/src/scss/_button-edit.scss @@ -123,11 +123,11 @@ table.feedback-table { grid-column: 1; } - .cell-bank-preview { + .cell-button-preview { grid-row: 2 / 5; grid-column: 2; - .bank { + .button-control { padding: 0; } } diff --git a/webui/src/scss/_button-grid.scss b/webui/src/scss/_button-grid.scss index 3a9013543d..205800b2b1 100644 --- a/webui/src/scss/_button-grid.scss +++ b/webui/src/scss/_button-grid.scss @@ -17,7 +17,7 @@ color: black; } -.bankgrid { +.buttongrid { -webkit-touch-callout: none !important; -webkit-user-select: none !important; user-select: none !important; @@ -26,11 +26,11 @@ grid-auto-flow: row; grid-auto-rows: 1fr; - &.bank-armed .pagebank-row { + &.button-armed .buttongrid-row { background-color: rgba(255, 0, 0, 0.5); } - .pagebank-row { + .buttongrid-row { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; @@ -96,7 +96,7 @@ border-radius: 5px; overflow: scroll; - &.bank-armed { + &.button-armed { background-color: #7f1111; } @@ -171,7 +171,7 @@ } } - .bank { + .button-control { position: absolute; float: left; } diff --git a/webui/src/scss/_tablet.scss b/webui/src/scss/_tablet.scss index c6a620c05c..cd7ad24a78 100644 --- a/webui/src/scss/_tablet.scss +++ b/webui/src/scss/_tablet.scss @@ -75,18 +75,18 @@ width: 100%; - .bank { + .button-control { border: none; position: absolute; float: left; } - .bank img { + .button-control img { max-width: 150px; max-height: 150px; } - &.bank-armed .pagebank-row { + &.button-armed .buttongrid-row { background-color: rgba(255, 0, 0, 0.5); } @@ -94,7 +94,7 @@ position: relative; } - .pagebank-row { + .buttongrid-row { display: grid; // grid-auto-flow: column; grid-auto-columns: 1fr; From 51d305a5821df2b6a61b08c399dbbe1935dc747a Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 18:48:54 +0000 Subject: [PATCH 39/53] chore: missed file --- webui/src/Buttons/ButtonInfiniteGrid.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/Buttons/ButtonInfiniteGrid.jsx b/webui/src/Buttons/ButtonInfiniteGrid.jsx index aa04787cf6..79493d667c 100644 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ b/webui/src/Buttons/ButtonInfiniteGrid.jsx @@ -160,7 +160,7 @@ export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid(
From 13984f3b4c3fb4f206cadfa5549573734f8e6432 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 20:16:02 +0100 Subject: [PATCH 40/53] fix: canvas font selection #2647 (#2648) --- lib/Graphics/Controller.js | 22 ++++++++++------------ lib/Graphics/Image.js | 13 ++++++------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index 83004368a5..32ee032f05 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -186,18 +186,16 @@ class GraphicsController extends CoreBase { FontLibrary.reset() FontLibrary.use({ - 'Companion-sans': ['assets/Fonts/Arimo-Regular.ttf'], - 'Companion-mono': ['assets/Fonts/NotoSansMono-wdth-wght.ttf'], - 'Companion-symbols': [ - 'assets/Fonts/NotoSansSymbols-wght.ttf', - 'assets/Fonts/NotoSansSymbols2-Regular.ttf', - 'assets/Fonts/NotoSansMath-Regular.ttf', - 'assets/Fonts/NotoMusic-Regular.ttf', - 'assets/Fonts/NotoSansLinearA-Regular.ttf', - 'assets/Fonts/NotoSansLinearB-Regular.ttf', - ], - 'Companion-emoji': ['assets/Fonts/NotoColorEmoji-compat.ttf'], - '5x7': ['assets/Fonts/pf_tempesta_seven.ttf'], + 'Companion-sans': 'assets/Fonts/Arimo-Regular.ttf', + 'Companion-mono': 'assets/Fonts/NotoSansMono-wdth-wght.ttf', + 'Companion-symbols1': 'assets/Fonts/NotoSansSymbols-wght.ttf', + 'Companion-symbols2': 'assets/Fonts/NotoSansSymbols2-Regular.ttf', + 'Companion-symbols3': 'assets/Fonts/NotoSansMath-Regular.ttf', + 'Companion-symbols4': 'assets/Fonts/NotoMusic-Regular.ttf', + 'Companion-symbols5': 'assets/Fonts/NotoSansLinearA-Regular.ttf', + 'Companion-symbols6': 'assets/Fonts/NotoSansLinearB-Regular.ttf', + 'Companion-emoji': 'assets/Fonts/NotoColorEmoji-compat.ttf', + '5x7': 'assets/Fonts/pf_tempesta_seven.ttf', }) this.fonts = FontLibrary.families } diff --git a/lib/Graphics/Image.js b/lib/Graphics/Image.js index 5a7a235451..c69087aa6a 100644 --- a/lib/Graphics/Image.js +++ b/lib/Graphics/Image.js @@ -19,6 +19,9 @@ import { Canvas, ImageData } from '@julusian/skia-canvas' import LogController from '../Log/Controller.js' import { PNG } from 'pngjs' +const DEFAULT_FONTS = + 'Companion-sans, Companion-symbols1, Companion-symbols2, Companion-symbols3, Companion-symbols4, Companion-symbols5, Companion-symbols6, Companion-emoji' + /** * @param {string | Buffer} pngData * @returns {Promise} @@ -355,8 +358,7 @@ class Image { if (isNaN(fontsize)) return 0 if (fontsize < 3) return 0 - let fontfamily = 'Companion-sans, Companion-emoji' - this.context2d.font = `${fontsize}px ${fontfamily}` + this.context2d.font = `${fontsize}px ${DEFAULT_FONTS}` const metrics = this.context2d.measureText(text) @@ -389,9 +391,7 @@ class Image { if (text === undefined || text.length == 0) return 0 if (halignment != 'left' && halignment != 'center' && halignment != 'right') halignment = 'left' - const fontfamily = 'Companion-sans, Companion-emoji' - - this.context2d.font = `${fontsize}px ${fontfamily}` + this.context2d.font = `${fontsize}px ${DEFAULT_FONTS}` this.context2d.fillStyle = color this.context2d.textAlign = halignment @@ -450,7 +450,6 @@ class Image { ) { // let textFits = true let lineheight - let fontfamily = 'Companion-sans, Companion-emoji' let fontheight if (text == undefined || text == '') { @@ -506,7 +505,7 @@ class Image { let breakPos = null //if (fontsize < 9) fontfamily = '7x5' - this.context2d.font = `${fontheight}px/${lineheight}px ${fontfamily}` + this.context2d.font = `${fontheight}px/${lineheight}px ${DEFAULT_FONTS}` this.context2d.textWrap = false /** From 71a54e8b05e0719e4b4a0e7ba90b12d0792e7bae Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 18 Nov 2023 20:26:58 +0000 Subject: [PATCH 41/53] chore: update changelog --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c04f0912c8..c49365c024 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,38 @@ ## Companion v3.2.0 - Release Notes (unreleased) +Up to 13984f3b4c3fb4f206cadfa5549573734f8e6432 + ### 📣 CORE FEATURES AND IMPROVEMENTS +- Button grid can be resized to be smaller or larger than the default 8x4 +- Rework button image drawing, to be higher resolution. This changes some font sizes slightly. + +- Improved surface rotation, which rotates the whole surface not just the drawing of each button +- Change surface image scaling library to reduce install size and improve performance +- Use async HID library, removing spawning of child processes to handle HID devices +- Add fontsize and image scaling to satellite api +- Elgato Plugin performance improvements +- Export and import compressed configs +- Add support for Loupedeck CT +- Add support for Videohub Panel as a surface +- Send compressed button renders to webui +- Emulators can have their grid size changed +- Tablet page performance improvements +- Bonjour discovery broker to assist modules in discovering possible devices to control +- Indicate variables support on text input fields +- Internal action to set or create custom variable +- Slow down connection initiaisation at startup, to avoid crashes on lower power machines +- Change webui build tooling to be more modern +- Rework backend code to be loosely typed +- Rework various api implementations, to support customisable grid size and avoid 'bank' terminology +- + ### 🐞 BUG FIXES +- Streamdeck Plus LCD strip image positioning +- + ### 🧩 NEW & UPDATED MODULES ## Companion v3.1.2 - Release Notes From 38a893e5f8d094453780fa049713f383ab7d115f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 19 Nov 2023 19:46:41 +0000 Subject: [PATCH 42/53] fix: drawing rgb bitmap from a feedback https://github.com/bitfocus/companion-module-shure-wireless/issues/25 --- lib/Graphics/Image.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/Graphics/Image.js b/lib/Graphics/Image.js index c69087aa6a..05fd9bcd71 100644 --- a/lib/Graphics/Image.js +++ b/lib/Graphics/Image.js @@ -836,6 +836,7 @@ class Image { // RGB: add alpha channel const rgb = Uint8Array.from(buffer) + buffer = Buffer.alloc(width * height * 4) for (let i = 0; i < rgb.length / 3; i += 1) { buffer[i * 4] = rgb[i * 3] // Red buffer[i * 4 + 1] = rgb[i * 3 + 1] // Green @@ -844,16 +845,9 @@ class Image { } } else { this.logger.error( - 'Pixelbuffer for a ' + - width + - 'x' + - height + - ' image should be either ' + - width * height * 3 + - ' or ' + - width * height * 4 + - ' bytes big. Not ' + - buffer.length + `Pixelbuffer for a ${width}x${height} image should be either ${width * height * 3} or ${ + width * height * 4 + } bytes big. Not ${buffer.length}` ) return } From a9f640f0a8c2c33ac1ba6bc4484ef0ef09d4d1d9 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 24 Nov 2023 00:31:23 +0000 Subject: [PATCH 43/53] fix: rework emulator styling to restrict grid to screen size, and remove max width (including for tablet view) #2656 #2659 --- webui/src/App.scss | 11 +-- webui/src/Emulator/Emulator.jsx | 128 +++++++++++++------------------- webui/src/scss/_emulator.scss | 43 +++++++++++ webui/src/scss/_tablet.scss | 2 +- 4 files changed, 98 insertions(+), 86 deletions(-) create mode 100644 webui/src/scss/_emulator.scss diff --git a/webui/src/App.scss b/webui/src/App.scss index c47d6d695b..181e040cde 100644 --- a/webui/src/App.scss +++ b/webui/src/App.scss @@ -7,6 +7,7 @@ @import 'scss/layout'; @import 'scss/common'; @import 'scss/tablet'; +@import 'scss/emulator'; @import 'scss/action-recorder'; @import 'scss/button-control'; @@ -80,16 +81,6 @@ } } -.page-emulator { - background-color: white; - padding: 1px; - - min-height: 100vh; - min-width: 1000px; - - text-align: center; -} - .modal-body img { // Cap images in modals to be the width of the modal max-width: 100%; diff --git a/webui/src/Emulator/Emulator.jsx b/webui/src/Emulator/Emulator.jsx index 4cceb8048e..e7ef6b2870 100644 --- a/webui/src/Emulator/Emulator.jsx +++ b/webui/src/Emulator/Emulator.jsx @@ -8,14 +8,13 @@ import { useMountEffect, PreventDefaultHandler, } from '../util' -import { CButton, CCol, CContainer, CForm, CRow } from '@coreui/react' +import { CButton, CCol, CForm, CRow } from '@coreui/react' import { nanoid } from 'nanoid' import { useParams } from 'react-router-dom' import { dsanMastercueKeymap, keyboardKeymap, logitecKeymap } from './Keymaps' import { ButtonPreview } from '../Components/ButtonPreview' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCancel, faExpand } from '@fortawesome/free-solid-svg-icons' -import { formatLocation } from '@companion/shared/ControlId' export function Emulator() { const socket = useContext(SocketContext) @@ -166,25 +165,23 @@ export function Emulator() { }, []) return ( -
- - {config ? ( - <> - - - - - ) : ( - - - - )} - +
+ {config ? ( + <> + + + + + ) : ( + + + + )}
) } @@ -234,11 +231,7 @@ function ConfigurePanel({ config }) { ) } -// function clamp(val, max) { -// return Math.min(Math.max(0, val), max) -// } - -function CyclePages({ imageCache, setKeyDown, columns, rows }) { +function EmulatorButtons({ imageCache, setKeyDown, columns, rows }) { const buttonClick = useCallback( (location, pressed) => { if (pressed) { @@ -250,57 +243,42 @@ function CyclePages({ imageCache, setKeyDown, columns, rows }) { [setKeyDown] ) + const gridStyle = useMemo(() => { + return { + gridTemplateColumns: 'minmax(0, 1fr) '.repeat(columns), + gridTemplateRows: 'minmax(0, 1fr) '.repeat(rows), + aspectRatio: `${columns} / ${rows}`, + height: `min(calc(100vw / ${columns} * ${rows}), 100vh)`, + width: `min(calc(100vh / ${rows} * ${columns}), 100vw)`, + } + }, [rows, columns]) + + const buttonElms = [] + for (let y = 0; y < rows; y++) { + for (let x = 0; x < columns; x++) { + buttonElms.push( + + ) + } + } + return ( - -
- - {/*
*/} -
- {/*

*/} - {/* {pages[currentPage]?.name || ' '} */} - - {/* {orderedPages.length > 1 && ( - <> - - - - - - - - )} */} - {/*

*/} -
-
- {' '} - {Array(rows) - .fill(0) - .map((_, y) => { - return ( - - {Array(columns) - .fill(0) - .map((_2, x) => { - return ( - - ) - })} - - ) - })} -
-
+ +
+
+ {buttonElms} +
- +
) } diff --git a/webui/src/scss/_emulator.scss b/webui/src/scss/_emulator.scss new file mode 100644 index 0000000000..5cab87692c --- /dev/null +++ b/webui/src/scss/_emulator.scss @@ -0,0 +1,43 @@ +.page-emulator { + background-color: #181818; + color: #d69e2e; + + height: 100vh; + + display: grid; + grid-template-rows: auto 1fr; + grid-auto-flow: column; + + .loading { + margin: 20% 0; + } + + .emulatorgrid { + background-color: #181818; + + display: grid; + // height: 100vh; + width: 100vw; + grid-row: 2; + + justify-content: space-around; + // grid-auto-flow: row; + + .buttongrid { + -webkit-touch-callout: none !important; + -webkit-user-select: none !important; + user-select: none !important; + + margin-top: auto; + margin-bottom: auto; + + display: grid; + grid-auto-flow: row; + grid-auto-rows: 1fr; + + .button-control { + padding: min(5px, 1%); + } + } + } +} diff --git a/webui/src/scss/_tablet.scss b/webui/src/scss/_tablet.scss index cd7ad24a78..b0d42a3f28 100644 --- a/webui/src/scss/_tablet.scss +++ b/webui/src/scss/_tablet.scss @@ -14,7 +14,7 @@ } .container-fluid { - max-width: 1200px; + // max-width: 1200px; height: 100%; margin: 0 auto; } From e27ea6a6ac72c6373e3125210a74dbf342244f34 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Sat, 25 Nov 2023 01:16:30 +0000 Subject: [PATCH 44/53] chore: update bundled-modules dd00ad9 update gdsys-muxkvmswitch to v1.0.0 aed155c update lofas-ndistudioclock to v0.1.0 346f865 update bmd-webpresenter to v2.1.0 56a4f9a update etcaudiovisuel-onlyview to 1.0.0 85dab9b update novastar-mediaserver to v1.0.2 1328eb2 update novastar-unico to v1.0.2 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index b2ae9c4562..dd00ad9c3e 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit b2ae9c4562b0d8d6a2992fc03577e2de1d64209b +Subproject commit dd00ad9c3edb7681bccd62742649cdfd96c112e4 From 1055e4b2196b3654be01c9c244716d5d09651d4c Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Sat, 25 Nov 2023 18:20:46 +0000 Subject: [PATCH 45/53] chore: update bundled-modules f92d53b update mvr-helios to v2.1.0 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index dd00ad9c3e..f92d53b7d6 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit dd00ad9c3edb7681bccd62742649cdfd96c112e4 +Subproject commit f92d53b7d60da2ad051ca3194d29bca09c445add From c052ee080631ea36372064031273f3ed83526c93 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 25 Nov 2023 22:26:50 +0000 Subject: [PATCH 46/53] feat: surface groups (#2655) --- lib/Data/ImportExport.js | 5 +- lib/Data/Metrics.js | 23 +- lib/Data/Model/ExportModel.ts | 1 + lib/Internal/Surface.js | 66 +- lib/Internal/Types.ts | 1 + lib/Surface/Controller.js | 697 +++++++++++++----- lib/Surface/Group.js | 366 +++++++++ lib/Surface/Handler.js | 196 ++--- webui/src/Controls/InternalInstanceFields.jsx | 34 +- webui/src/Surfaces/AddGroupModal.jsx | 79 ++ webui/src/Surfaces/EditModal.jsx | 529 ++++++++----- webui/src/Surfaces/index.jsx | 256 ++++--- webui/src/scss/_common.scss | 8 + 13 files changed, 1600 insertions(+), 661 deletions(-) create mode 100644 lib/Surface/Group.js create mode 100644 webui/src/Surfaces/AddGroupModal.jsx diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 0bdd0c23b7..a58070566d 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -363,6 +363,7 @@ class DataImportExport extends CoreBase { if (!config || !isFalsey(config.surfaces)) { exp.surfaces = this.surfaces.exportAll(false) + exp.surfaceGroups = this.surfaces.exportAllGroups(false) } return exp @@ -790,9 +791,7 @@ class DataImportExport extends CoreBase { } if (!config || config.surfaces) { - for (const [id, surface] of Object.entries(data.surfaces || {})) { - this.surfaces.importSurface(id, surface) - } + this.surfaces.importSurfaces(data.surfaceGroups || {}, data.surfaces || {}) } if (!config || config.triggers) { diff --git a/lib/Data/Metrics.js b/lib/Data/Metrics.js index 81676c2060..44db4442a1 100644 --- a/lib/Data/Metrics.js +++ b/lib/Data/Metrics.js @@ -33,23 +33,26 @@ class DataMetrics extends CoreBase { #cycle() { this.logger.silly('cycle') - const devices = this.surfaces.getDevicesList().available - /** * @type {string[]} */ const relevantDevices = [] try { - Object.values(devices).forEach((device) => { - if (device.id !== undefined && !device.id.startsWith('emulator:')) { - // remove leading "satellite-" from satellite device serial numbers. - const serialNumber = device.id.replace('satellite-', '') - // normalize serialNumber by md5 hashing it, we don't want/need the specific serialNumber anyways. - const deviceHash = crypto.createHash('md5').update(serialNumber).digest('hex') - if (deviceHash && deviceHash.length === 32) relevantDevices.push(deviceHash) + const surfaceGroups = this.surfaces.getDevicesList() + for (const surfaceGroup of surfaceGroups) { + if (!surfaceGroup.surfaces) continue + + for (const surface of surfaceGroup.surfaces) { + if (surface.id && surface.isConnected && !surface.id.startsWith('emulator:')) { + // remove leading "satellite-" from satellite device serial numbers. + const serialNumber = surface.id.replace('satellite-', '') + // normalize serialnumber by md5 hashing it, we don't want/need the specific serialnumber anyways. + const deviceHash = crypto.createHash('md5').update(serialNumber).digest('hex') + if (deviceHash && deviceHash.length === 32) relevantDevices.push(deviceHash) + } } - }) + } } catch (e) { // don't care } diff --git a/lib/Data/Model/ExportModel.ts b/lib/Data/Model/ExportModel.ts index 0227358e5e..d53ddaf656 100644 --- a/lib/Data/Model/ExportModel.ts +++ b/lib/Data/Model/ExportModel.ts @@ -14,6 +14,7 @@ export interface ExportFullv4 extends ExportBase<'full'> { custom_variables?: CustomVariablesModel instances?: ExportInstancesv4 surfaces?: unknown + surfaceGroups?: unknown } export interface ExportPageModelv4 extends ExportBase<'page'> { diff --git a/lib/Internal/Surface.js b/lib/Internal/Surface.js index 9cc52930c3..85763933a0 100644 --- a/lib/Internal/Surface.js +++ b/lib/Internal/Surface.js @@ -18,17 +18,34 @@ import { combineRgb } from '@companion-module/base' import LogController from '../Log/Controller.js' -/** @type {import('./Types.js').InternalActionInputField} */ -const CHOICES_CONTROLLER = { - type: 'internal:surface_serial', - label: 'Surface / controller', - id: 'controller', - default: 'self', - includeSelf: true, -} +/** @type {import('./Types.js').InternalActionInputField[]} */ +const CHOICES_SURFACE_GROUP_WITH_VARIABLES = [ + { + type: 'checkbox', + label: 'Use variables for surface', + id: 'controller_from_variable', + default: false, + }, + { + type: 'internal:surface_serial', + label: 'Surface / group', + id: 'controller', + default: 'self', + includeSelf: true, + isVisible: (options) => !options.controller_from_variable, + }, + { + type: 'textinput', + label: 'Surface / group', + id: 'controller_variable', + default: 'self', + isVisible: (options) => !!options.controller_from_variable, + useVariables: true, + }, +] /** @type {import('./Types.js').InternalActionInputField[]} */ -const CHOICES_CONTROLLER_WITH_VARIABLES = [ +const CHOICES_SURFACE_ID_WITH_VARIABLES = [ { type: 'checkbox', label: 'Use variables for surface', @@ -36,12 +53,17 @@ const CHOICES_CONTROLLER_WITH_VARIABLES = [ default: false, }, { - ...CHOICES_CONTROLLER, + type: 'internal:surface_serial', + label: 'Surface / group', + id: 'controller', + default: 'self', + includeSelf: true, + useRawSurfaces: true, isVisible: (options) => !options.controller_from_variable, }, { type: 'textinput', - label: 'Surface / controller', + label: 'Surface / group', id: 'controller_variable', default: 'self', isVisible: (options) => !!options.controller_from_variable, @@ -215,9 +237,9 @@ export default class Surface { getActionDefinitions() { return { set_brightness: { - label: 'Surface: Set serialNumber to brightness', + label: 'Surface: Set to brightness', options: [ - ...CHOICES_CONTROLLER_WITH_VARIABLES, + ...CHOICES_SURFACE_ID_WITH_VARIABLES, { type: 'number', @@ -233,8 +255,8 @@ export default class Surface { }, set_page: { - label: 'Surface: Set serialNumber to page', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES, ...CHOICES_PAGE_WITH_VARIABLES], + label: 'Surface: Set to page', + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES, ...CHOICES_PAGE_WITH_VARIABLES], }, set_page_byindex: { label: 'Surface: Set by index to page', @@ -255,20 +277,20 @@ export default class Surface { inc_page: { label: 'Surface: Increment page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, dec_page: { label: 'Surface: Decrement page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_device: { label: 'Surface: Lockout specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, unlockout_device: { label: 'Surface: Unlock specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_all: { @@ -346,7 +368,7 @@ export default class Surface { } setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, true, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, true, true) }) } return true @@ -355,7 +377,7 @@ export default class Surface { if (!theController) return true setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, false, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, false, true) }) return true @@ -467,7 +489,7 @@ export default class Surface { options: [ { type: 'internal:surface_serial', - label: 'Surface / controller', + label: 'Surface / group', id: 'controller', }, { diff --git a/lib/Internal/Types.ts b/lib/Internal/Types.ts index 1a2e239208..0023bb2e8d 100644 --- a/lib/Internal/Types.ts +++ b/lib/Internal/Types.ts @@ -40,6 +40,7 @@ export type InternalInputField = ( type: 'internal:surface_serial' includeSelf: boolean default: string + useRawSurfaces?: boolean } | { type: 'internal:page' diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index c97230c503..1dc7393621 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -1,3 +1,4 @@ +// @ts-check /* * This file is part of the Companion project * Copyright (c) 2018 Bitfocus AS @@ -26,21 +27,19 @@ import { usb } from 'usb' // @ts-ignore import shuttleControlUSB from 'shuttle-control-usb' import { listLoupedecks, LoupedeckModelId } from '@loupedeck/node' - -import SurfaceHandler from './Handler.js' +import SurfaceHandler, { getSurfaceName } from './Handler.js' import SurfaceIPElgatoEmulator, { EmulatorRoom } from './IP/ElgatoEmulator.js' import SurfaceIPElgatoPlugin from './IP/ElgatoPlugin.js' import SurfaceIPSatellite from './IP/Satellite.js' - import ElgatoStreamDeckDriver from './USB/ElgatoStreamDeck.js' import InfinittonDriver from './USB/Infinitton.js' import XKeysDriver from './USB/XKeys.js' import LoupedeckLiveDriver from './USB/LoupedeckLive.js' import SurfaceUSBLoupedeckCt from './USB/LoupedeckCt.js' import ContourShuttleDriver from './USB/ContourShuttle.js' - -import CoreBase from '../Core/Base.js' import SurfaceIPVideohubPanel from './IP/VideohubPanel.js' +import CoreBase from '../Core/Base.js' +import { SurfaceGroup } from './Group.js' // Force it to load the hidraw driver just in case HID.setDriverType('hidraw') @@ -51,7 +50,7 @@ const SurfacesRoom = 'surfaces' class SurfaceController extends CoreBase { /** * The last sent json object - * @type {ClientDevicesList | null} + * @type {ClientDevicesListItem[] | null} * @access private */ #lastSentJson = null @@ -63,6 +62,13 @@ class SurfaceController extends CoreBase { */ #surfaceHandlers = new Map() + /** + * The surface groups wrapping the surface handlers + * @type {Map} + * @access private + */ + #surfaceGroups = new Map() + /** * Last time each surface was interacted with, for lockouts * The values get cleared when a surface is locked, and remains while unlocked @@ -107,8 +113,15 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!this.userconfig.getKey('link_lockouts') - // Setup defined emulators - { + setImmediate(() => { + // Setup groups + const groupsConfigs = this.db.getKey('surface-groups', {}) + for (const groupId of Object.keys(groupsConfigs)) { + const newGroup = new SurfaceGroup(this.registry, groupId, null, this.isPinLockEnabled()) + this.#surfaceGroups.set(groupId, newGroup) + } + + // Setup defined emulators const instances = this.db.getKey('deviceconfig', {}) || {} for (const id of Object.keys(instances)) { // If the id starts with 'emulator:' then re-add it @@ -116,14 +129,12 @@ class SurfaceController extends CoreBase { this.addEmulator(id.substring(9)) } } - } - // Initial search for USB devices - this.#refreshDevices().catch(() => { - this.logger.warn('Initial USB scan failed') - }) + // Initial search for USB devices + this.#refreshDevices().catch(() => { + this.logger.warn('Initial USB scan failed') + }) - setImmediate(() => { this.updateDevicesList() this.#startStopLockoutTimer() @@ -189,10 +200,10 @@ class SurfaceController extends CoreBase { if (this.#surfacesAllLocked) return let doLockout = false - for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { + for (const surfaceGroup of this.#surfaceGroups.values()) { + if (this.#isSurfaceGroupTimedOut(surfaceGroup.groupId, timeout)) { doLockout = true - this.#surfacesLastInteraction.delete(device.surfaceId) + this.#surfacesLastInteraction.delete(surfaceGroup.groupId) } } @@ -200,10 +211,10 @@ class SurfaceController extends CoreBase { this.setAllLocked(true) } } else { - for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { - this.#surfacesLastInteraction.delete(device.surfaceId) - this.setDeviceLocked(device.surfaceId, true) + for (const surfaceGroup of this.#surfaceGroups.values()) { + if (this.#isSurfaceGroupTimedOut(surfaceGroup.groupId, timeout)) { + this.#surfacesLastInteraction.delete(surfaceGroup.groupId) + this.setSurfaceOrGroupLocked(surfaceGroup.groupId, true) } } } @@ -213,14 +224,14 @@ class SurfaceController extends CoreBase { /** * Check if a surface should be timed out - * @param {string} surfaceId + * @param {string} groupId * @param {number} timeout * @returns {boolean} */ - #isSurfaceTimedOut(surfaceId, timeout) { + #isSurfaceGroupTimedOut(groupId, timeout) { if (!this.isPinLockEnabled()) return false - const lastInteraction = this.#surfacesLastInteraction.get(surfaceId) || 0 + const lastInteraction = this.#surfacesLastInteraction.get(groupId) || 0 return lastInteraction + timeout < Date.now() } @@ -254,20 +265,6 @@ class SurfaceController extends CoreBase { * @returns {void} */ #createSurfaceHandler(surfaceId, integrationType, panel) { - const panelSurfaceId = panel.info.deviceId - - let isLocked = false - if (this.isPinLockEnabled()) { - const timeout = Number(this.userconfig.getKey('pin_timeout')) * 1000 - if (this.userconfig.getKey('link_lockouts')) { - isLocked = this.#surfacesAllLocked - } else if (timeout && !isNaN(timeout)) { - isLocked = this.#isSurfaceTimedOut(panelSurfaceId, timeout) - } else { - isLocked = !this.#surfacesLastInteraction.has(panelSurfaceId) - } - } - const surfaceConfig = this.getDeviceConfig(panel.info.deviceId) if (!surfaceConfig) { this.logger.silly(`Creating config for newly discovered device ${panel.info.deviceId}`) @@ -275,26 +272,29 @@ class SurfaceController extends CoreBase { this.logger.silly(`Reusing config for device ${panel.info.deviceId}`) } - const handler = new SurfaceHandler(this.registry, integrationType, panel, isLocked, surfaceConfig) + const handler = new SurfaceHandler(this.registry, integrationType, panel, surfaceConfig) handler.on('interaction', () => { - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) + const groupId = handler.getGroupId() || handler.surfaceId + this.#surfacesLastInteraction.set(groupId, Date.now()) }) handler.on('configUpdated', (newConfig) => { this.setDeviceConfig(handler.surfaceId, newConfig) }) handler.on('unlocked', () => { - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) + const groupId = handler.getGroupId() || handler.surfaceId + this.#surfacesLastInteraction.set(groupId, Date.now()) if (this.userconfig.getKey('link_lockouts')) { this.setAllLocked(false) + } else { + this.setSurfaceOrGroupLocked(groupId, false) } }) this.#surfaceHandlers.set(surfaceId, handler) - if (!isLocked) { - // If not already locked, keep it unlocked for the full timeout - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) - } + + // Update the group to have the new surface + this.#attachSurfaceToGroup(handler) } /** @@ -312,15 +312,15 @@ class SurfaceController extends CoreBase { (id) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance || !(instance.panel instanceof SurfaceIPElgatoEmulator)) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface || !(surface.panel instanceof SurfaceIPElgatoEmulator)) { throw new Error(`Emulator "${id}" does not exist!`) } // Subscribe to the bitmaps client.join(fullId) - return instance.panel.setupClient(client) + return surface.panel.setupClient(client) } ) @@ -335,12 +335,12 @@ class SurfaceController extends CoreBase { (id, x, y) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface) { throw new Error(`Emulator "${id}" does not exist!`) } - instance.panel.emit('click', x, y, true) + surface.panel.emit('click', x, y, true) } ) @@ -355,12 +355,12 @@ class SurfaceController extends CoreBase { (id, x, y) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface) { throw new Error(`Emulator "${id}" does not exist!`) } - instance.panel.emit('click', x, y, false) + surface.panel.emit('click', x, y, false) } ) @@ -402,12 +402,33 @@ class SurfaceController extends CoreBase { * @returns {void} */ (id, name) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - instance.setPanelName(name) + // Find a matching group + const group = this.#surfaceGroups.get(id) + if (group && !group.isAutoGroup) { + group.setName(name) + this.updateDevicesList() + return + } + + // Find a connected surface + for (let surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + surface.setPanelName(name) this.updateDevicesList() + return } } + + // Find a disconnected surface + const configs = this.db.getKey('deviceconfig', {}) + if (configs[id]) { + configs[id].name = name + this.db.setKey('deviceconfig', configs) + this.updateDevicesList() + return + } + + throw new Error('not found') } ) @@ -418,9 +439,9 @@ class SurfaceController extends CoreBase { * @returns {[config: unknown, info: unknown] | null} */ (id) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - return instance.getPanelConfig() + for (const surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + return surface.getPanelConfig() } } return null @@ -435,10 +456,10 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ (id, config) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - instance.setPanelConfig(config) - return instance.getPanelConfig() + for (let surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + surface.setPanelConfig(config) + return surface.getPanelConfig() } } return 'device not found' @@ -483,8 +504,8 @@ class SurfaceController extends CoreBase { * @returns {string | true} */ (id) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { + for (let surface of this.#surfaceHandlers.values()) { + if (surface.surfaceId == id) { return 'device is active' } } @@ -498,6 +519,172 @@ class SurfaceController extends CoreBase { return 'device not found' } ) + + client.onPromise( + 'surfaces:group-add', + /** + * @param {string} name + * @returns {string} + */ + (name) => { + if (!name || typeof name !== 'string') throw new Error('Invalid name') + + // TODO - should this do friendlier ids? + const groupId = `group:${nanoid()}` + + const newGroup = new SurfaceGroup(this.registry, groupId, null, this.isPinLockEnabled()) + newGroup.setName(name) + this.#surfaceGroups.set(groupId, newGroup) + + this.updateDevicesList() + + return groupId + } + ) + + client.onPromise( + 'surfaces:group-remove', + /** + * @param {string} groupId + * @returns {string} + */ + (groupId) => { + const group = this.#surfaceGroups.get(groupId) + if (!group || group.isAutoGroup) throw new Error(`Group does not exist`) + + // Clear the group for all surfaces + for (const surfaceHandler of group.surfaceHandlers) { + surfaceHandler.setGroupId(null) + this.#attachSurfaceToGroup(surfaceHandler) + } + + group.dispose() + group.forgetConfig() + this.#surfaceGroups.delete(groupId) + + this.updateDevicesList() + + return groupId + } + ) + + client.onPromise( + 'surfaces:add-to-group', + /** + * @param {string} groupId + * @param {string} surfaceId + * @returns {void} + */ + (groupId, surfaceId) => { + const group = groupId ? this.#surfaceGroups.get(groupId) : null + if (groupId && !group) throw new Error(`Group does not exist: ${groupId}`) + + const surfaceHandler = Array.from(this.#surfaceHandlers.values()).find( + (surface) => surface.surfaceId === surfaceId + ) + if (!surfaceHandler) throw new Error(`Surface does not exist or is not connected: ${surfaceId}`) + // TODO - we can handle this if it is still in the config + + this.#detachSurfaceFromGroup(surfaceHandler) + + surfaceHandler.setGroupId(groupId) + + this.#attachSurfaceToGroup(surfaceHandler) + + this.updateDevicesList() + } + ) + + client.onPromise( + 'surfaces:group-config-get', + /** + * @param {string} groupId + * @returns {any} + */ + (groupId) => { + const group = this.#surfaceGroups.get(groupId) + if (!group) throw new Error(`Group does not exist: ${groupId}`) + + return group.groupConfig + } + ) + + client.onPromise( + 'surfaces:group-config-set', + /** + * @param {string} groupId + * @param {string} key + * @param {any} value + * @returns {any} + */ + (groupId, key, value) => { + const group = this.#surfaceGroups.get(groupId) + if (!group) throw new Error(`Group does not exist: ${groupId}`) + + const err = group.setGroupConfigValue(key, value) + if (err) return err + + return group.groupConfig + } + ) + } + + /** + * Attach a `SurfaceHandler` to its `SurfaceGroup` + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + #attachSurfaceToGroup(surfaceHandler) { + const rawSurfaceGroupId = surfaceHandler.getGroupId() + const surfaceGroupId = rawSurfaceGroupId || surfaceHandler.surfaceId + const existingGroup = this.#surfaceGroups.get(surfaceGroupId) + if (existingGroup) { + existingGroup.attachSurface(surfaceHandler) + } else { + let isLocked = false + if (this.isPinLockEnabled()) { + const timeout = Number(this.userconfig.getKey('pin_timeout')) * 1000 + if (this.userconfig.getKey('link_lockouts')) { + isLocked = this.#surfacesAllLocked + } else if (timeout && !isNaN(timeout)) { + isLocked = this.#isSurfaceGroupTimedOut(surfaceGroupId, timeout) + } else { + isLocked = !this.#surfacesLastInteraction.has(surfaceGroupId) + } + } + + if (!isLocked) { + // If not already locked, keep it unlocked for the full timeout + this.#surfacesLastInteraction.set(surfaceGroupId, Date.now()) + } + + const newGroup = new SurfaceGroup( + this.registry, + surfaceGroupId, + !rawSurfaceGroupId ? surfaceHandler : null, + isLocked + ) + this.#surfaceGroups.set(surfaceGroupId, newGroup) + } + } + + /** + * Detach a `SurfaceHandler` from its `SurfaceGroup` + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + #detachSurfaceFromGroup(surfaceHandler) { + const existingGroupId = surfaceHandler.getGroupId() || surfaceHandler.surfaceId + const existingGroup = existingGroupId ? this.#surfaceGroups.get(existingGroupId) : null + if (!existingGroup) return + + existingGroup.detachSurface(surfaceHandler) + + // Cleanup an auto surface group + if (existingGroup.isAutoGroup) { + existingGroup.dispose() + this.#surfaceGroups.delete(existingGroupId) + } } /** @@ -532,100 +719,128 @@ class SurfaceController extends CoreBase { /** * - * @returns {ClientDevicesList} + * @returns {ClientDevicesListItem[]} */ getDevicesList() { - /** @type {AvailableDeviceInfo[]} */ - const availableDevicesInfo = [] - /** @type {OfflineDeviceInfo[]} */ - const offlineDevicesInfo = [] - - const config = this.db.getKey('deviceconfig', {}) - - const instanceMap = new Map() - for (const instance of this.#surfaceHandlers.values()) { - instanceMap.set(instance.surfaceId, instance) - } - - const surfaceIds = Array.from(new Set([...Object.keys(config), ...instanceMap.keys()])) - for (const id of surfaceIds) { - const instance = instanceMap.get(id) - const conf = config[id] - - /** @type {BaseDeviceInfo} */ - const commonInfo = { + /** + * + * @param {string} id + * @param {Record} config + * @param {SurfaceHandler | null} surfaceHandler + * @returns {ClientSurfaceItem} + */ + function translateSurfaceConfig(id, config, surfaceHandler) { + /** @type {ClientSurfaceItem} */ + const surfaceInfo = { id: id, - type: conf?.type || 'Unknown', - integrationType: conf?.integrationType || '', - name: conf?.name || '', - index: 0, // Fixed later + type: config?.type || 'Unknown', + integrationType: config?.integrationType || '', + name: config?.name || '', + // location: 'Offline', + configFields: [], + isConnected: !!surfaceHandler, + displayName: getSurfaceName(config, id), + location: null, } - if (instance) { - let location = instance.panel.info.location + if (surfaceHandler) { + let location = surfaceHandler.panel.info.location if (location && location.startsWith('::ffff:')) location = location.substring(7) - availableDevicesInfo.push({ - ...commonInfo, - location: location || 'Local', - configFields: instance.panel.info.configFields || [], - }) - } else { - offlineDevicesInfo.push({ - ...commonInfo, - }) + surfaceInfo.location = location || null + surfaceInfo.configFields = surfaceHandler.panel.info.configFields || [] } + + return surfaceInfo } - /** - * @param {BaseDeviceInfo} a - * @param {BaseDeviceInfo} b - * @returns -1 | 0 | 1 - */ - function sortDevices(a, b) { - // emulator must be first - if (a.id === 'emulator') { - return -1 - } else if (b.id === 'emulator') { - return 1 + /** @type {ClientDevicesListItem[]} */ + const result = [] + + const surfaceGroups = Array.from(this.#surfaceGroups.values()) + surfaceGroups.sort( + /** + * @param {SurfaceGroup} a + * @param {SurfaceGroup} b + * @returns -1 | 0 | 1 + */ + (a, b) => { + // manual groups must be first + if (!a.isAutoGroup && b.isAutoGroup) { + return -1 + } else if (!b.isAutoGroup && a.isAutoGroup) { + return 1 + } + + const aIsEmulator = a.groupId.startsWith('emulator:') + const bIsEmulator = b.groupId.startsWith('emulator:') + + // emulator must be first + if (aIsEmulator && !bIsEmulator) { + return -1 + } else if (bIsEmulator && !aIsEmulator) { + return 1 + } + + // then by id + return a.groupId.localeCompare(b.groupId) } + ) - // sort by type first - const type = a.type.localeCompare(b.type) - if (type !== 0) { - return type + const groupsMap = new Map() + surfaceGroups.forEach((group, index) => { + /** @type {ClientDevicesListItem} */ + const groupResult = { + id: group.groupId, + index: index, + displayName: group.displayName, + isAutoGroup: group.isAutoGroup, + surfaces: group.surfaceHandlers.map((handler) => + translateSurfaceConfig(handler.surfaceId, handler.getFullConfig(), handler) + ), } + result.push(groupResult) + groupsMap.set(group.groupId, groupResult) + }) - // then by serial - return a.id.localeCompare(b.id) + const mappedSurfaceId = new Set() + for (const group of result) { + for (const surface of group.surfaces) { + mappedSurfaceId.add(surface.id) + } } - availableDevicesInfo.sort(sortDevices) - offlineDevicesInfo.sort(sortDevices) - /** @type {ClientDevicesList} */ - const res = { - available: {}, - offline: {}, - } - availableDevicesInfo.forEach((info, index) => { - res.available[info.id] = { - ...info, - index, - } - }) - offlineDevicesInfo.forEach((info, index) => { - res.offline[info.id] = { - ...info, - index, + // Add any automatic groups for offline surfaces + const config = this.db.getKey('deviceconfig', {}) + for (const [surfaceId, surface] of Object.entries(config)) { + if (mappedSurfaceId.has(surfaceId)) continue + + const groupId = surface.groupId || surfaceId + + const existingGroup = groupsMap.get(groupId) + if (existingGroup) { + existingGroup.surfaces.push(translateSurfaceConfig(surfaceId, surface, null)) + } else { + /** @type {ClientDevicesListItem} */ + const groupResult = { + id: groupId, + index: result.length, + displayName: `${surface.name || surface.type} (${surfaceId}) - Offline`, + isAutoGroup: true, + surfaces: [translateSurfaceConfig(surfaceId, surface, null)], + } + result.push(groupResult) + groupsMap.set(groupId, groupResult) } - }) + } - return res + return result } reset() { // Each active handler will re-add itself when doing the save as part of its own reset this.db.setKey('deviceconfig', {}) + this.db.setKey('surface-groups', {}) this.#resetAllDevices() this.updateDevicesList() } @@ -744,7 +959,7 @@ class SurfaceController extends CoreBase { ? listLoupedecks().then((deviceInfos) => Promise.allSettled( deviceInfos.map(async (deviceInfo) => { - this.logger.log('found loupedeck', deviceInfo) + this.logger.info('found loupedeck', deviceInfo) if (!this.#surfaceHandlers.has(deviceInfo.path)) { if ( deviceInfo.model === LoupedeckModelId.LoupedeckLive || @@ -898,20 +1113,53 @@ class SurfaceController extends CoreBase { return clone ? cloneDeep(obj) : obj } + exportAllGroups(clone = true) { + const obj = this.db.getKey('surface-groups', {}) || {} + return clone ? cloneDeep(obj) : obj + } + /** * Import a surface configuration - * @param {string} surfaceId - * @param {*} config + * @param {Record} surfaceGroups + * @param {Record} surfaces * @returns {void} */ - importSurface(surfaceId, config) { - const device = this.#getSurfaceHandlerForId(surfaceId, true) - if (device) { - // Device is currently loaded - device.setPanelConfig(config) - } else { - // Device is not loaded - this.setDeviceConfig(surfaceId, config) + importSurfaces(surfaceGroups, surfaces) { + for (const [id, surfaceGroup] of Object.entries(surfaceGroups)) { + let group = this.#getGroupForId(id, true) + if (!group) { + // Group does not exist + group = new SurfaceGroup(this.registry, id, null, this.isPinLockEnabled()) + this.#surfaceGroups.set(id, group) + } + + // Sync config + group.setName(surfaceGroup.name ?? '') + for (const [key, value] of Object.entries(surfaceGroup)) { + if (key === 'name') continue + group.setGroupConfigValue(key, value) + } + } + + for (const [surfaceId, surfaceConfig] of Object.entries(surfaces)) { + const surface = this.#getSurfaceHandlerForId(surfaceId, true) + if (surface) { + // Device is currently loaded + surface.setPanelConfig(surfaceConfig.config) + surface.saveGroupConfig(surfaceConfig.groupConfig) + surface.setPanelName(surfaceConfig.name) + + // Update the groupId + const newGroupId = surfaceConfig.groupId ?? null + if (surface.getGroupId() !== newGroupId && this.#getGroupForId(newGroupId)) { + this.#detachSurfaceFromGroup(surface) + surface.setGroupId(newGroupId) + this.#attachSurfaceToGroup(surface) + } + } else { + // Device is not loaded + this.setDeviceConfig(surfaceId, surfaceConfig) + } } this.updateDevicesList() @@ -924,17 +1172,20 @@ class SurfaceController extends CoreBase { * @returns {void} */ removeDevice(devicePath, purge) { - const existingSurface = this.#surfaceHandlers.get(devicePath) - if (existingSurface) { + const surfaceHandler = this.#surfaceHandlers.get(devicePath) + if (surfaceHandler) { this.logger.silly('remove device ' + devicePath) + // Detach surface from any group + this.#detachSurfaceFromGroup(surfaceHandler) + try { - existingSurface.unload(purge) + surfaceHandler.unload(purge) } catch (e) { // Ignore for now } - existingSurface.removeAllListeners() + surfaceHandler.removeAllListeners() this.#surfaceHandlers.delete(devicePath) } @@ -943,9 +1194,10 @@ class SurfaceController extends CoreBase { } quit() { - for (const device of this.#surfaceHandlers.values()) { + for (const surface of this.#surfaceHandlers.values()) { + if (!surface) continue try { - device.unload() + surface.unload() } catch (e) { // Ignore for now } @@ -961,7 +1213,7 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ getDeviceIdFromIndex(index) { - for (const dev of Object.values(this.getDevicesList().available)) { + for (const dev of this.getDevicesList()) { if (dev.index === index) { return dev.id } @@ -971,63 +1223,75 @@ class SurfaceController extends CoreBase { /** * Perform page-up for a surface - * @param {string} surfaceId - * @param {boolean} looseIdMatching + * @param {string} surfaceOrGroupId + * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageUp(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.doPageUp() + devicePageUp(surfaceOrGroupId, looseIdMatching) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.doPageUp() } } /** * Perform page-down for a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageDown(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.doPageDown() + devicePageDown(surfaceOrGroupId, looseIdMatching) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.doPageDown() } } /** * Set the page number for a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {number} page * @param {boolean=} looseIdMatching * @param {boolean=} defer Defer the drawing to the next tick * @returns {void} */ - devicePageSet(surfaceId, page, looseIdMatching = false, defer = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.setCurrentPage(page, defer) + devicePageSet(surfaceOrGroupId, page, looseIdMatching, defer = false) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.setCurrentPage(page, defer) } } /** * Get the page number of a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {number | undefined} */ - devicePageGet(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - return device.getCurrentPage() + devicePageGet(surfaceOrGroupId, looseIdMatching = false) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + return surfaceGroup.getCurrentPage() } else { return undefined } } #resetAllDevices() { - for (const device of this.#surfaceHandlers.values()) { + // Destroy any groups and detach their contents + for (const surfaceGroup of this.#surfaceGroups.values()) { + for (const surface of surfaceGroup.surfaceHandlers) { + surfaceGroup.detachSurface(surface) + } + surfaceGroup.dispose() + } + this.#surfaceGroups.clear() + + // Re-attach in auto-groups + for (const surface of this.#surfaceHandlers.values()) { try { - device.resetConfig() + surface.resetConfig() + + this.#attachSurfaceToGroup(surface) } catch (e) { - this.logger.warn('Could not reset a device') + this.logger.warn('Could not reattach a surface') } } } @@ -1055,21 +1319,21 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!locked - for (const device of this.#surfaceHandlers.values()) { - this.#surfacesLastInteraction.set(device.surfaceId, Date.now()) + for (const surfaceGroup of this.#surfaceGroups.values()) { + this.#surfacesLastInteraction.set(surfaceGroup.groupId, Date.now()) - device.setLocked(!!locked) + surfaceGroup.setLocked(!!locked) } } /** * Set all surfaces as locked - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean} locked * @param {boolean} looseIdMatching * @returns {void} */ - setDeviceLocked(surfaceId, locked, looseIdMatching = false) { + setSurfaceOrGroupLocked(surfaceOrGroupId, locked, looseIdMatching = false) { if (!this.isPinLockEnabled()) return if (this.userconfig.getKey('link_lockouts')) { @@ -1079,14 +1343,14 @@ class SurfaceController extends CoreBase { // Track the lock/unlock state, even if the device isn't online if (locked) { - this.#surfacesLastInteraction.delete(surfaceId) + this.#surfacesLastInteraction.delete(surfaceOrGroupId) } else { - this.#surfacesLastInteraction.set(surfaceId, Date.now()) + this.#surfacesLastInteraction.set(surfaceOrGroupId, Date.now()) } - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.setLocked(!!locked) + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.setLocked(!!locked) } } } @@ -1105,6 +1369,25 @@ class SurfaceController extends CoreBase { } } + /** + * Get the `SurfaceGroup` for a surfaceId or groupId + * @param {string} surfaceOrGroupId + * @param {boolean} looseIdMatching + * @returns {SurfaceGroup | undefined} + */ + #getGroupForId(surfaceOrGroupId, looseIdMatching = false) { + const matchingGroup = this.#surfaceGroups.get(surfaceOrGroupId) + if (matchingGroup) return matchingGroup + + const surface = this.#getSurfaceHandlerForId(surfaceOrGroupId, looseIdMatching) + if (surface) { + const groupId = surface.getGroupId() || surface.surfaceId + return this.#surfaceGroups.get(groupId) + } + + return undefined + } + /** * Get the `SurfaceHandler` for a surfaceId * @param {string} surfaceId @@ -1114,28 +1397,28 @@ class SurfaceController extends CoreBase { #getSurfaceHandlerForId(surfaceId, looseIdMatching) { if (surfaceId === 'emulator') surfaceId = 'emulator:emulator' - const instances = Array.from(this.#surfaceHandlers.values()) + const surfaces = Array.from(this.#surfaceHandlers.values()) // try and find exact match - let device = instances.find((d) => d.surfaceId === surfaceId) - if (device) return device + let surface = surfaces.find((d) => d.surfaceId === surfaceId) + if (surface) return surface // only try more variations if the id isnt new format if (!looseIdMatching || surfaceId.includes(':')) return undefined // try the most likely streamdeck prefix let surfaceId2 = `streamdeck:${surfaceId}` - device = instances.find((d) => d.surfaceId === surfaceId2) - if (device) return device + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // it is unlikely, but it could be a loupedeck surfaceId2 = `loupedeck:${surfaceId}` - device = instances.find((d) => d.surfaceId === surfaceId2) - if (device) return device + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // or maybe a satellite? surfaceId2 = `satellite-${surfaceId}` - return instances.find((d) => d.surfaceId === surfaceId2) + return surfaces.find((d) => d.surfaceId === surfaceId2) } } @@ -1158,9 +1441,23 @@ export default SurfaceController * } & BaseDeviceInfo} AvailableDeviceInfo * * @typedef {{ - * available: Record - * offline: Record - * }} ClientDevicesList + * id: string + * type: string + * integrationType: string + * name: string + * configFields: string[] + * isConnected: boolean + * displayName: string + * location: string | null + * }} ClientSurfaceItem + * + * @typedef {{ + * id: string + * index: number + * displayName: string + * isAutoGroup: boolean + * surfaces: ClientSurfaceItem[] + * }} ClientDevicesListItem */ /** diff --git a/lib/Surface/Group.js b/lib/Surface/Group.js new file mode 100644 index 0000000000..9b1d4adc32 --- /dev/null +++ b/lib/Surface/Group.js @@ -0,0 +1,366 @@ +// @ts-check +/* + * 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 { cloneDeep } from 'lodash-es' +import CoreBase from '../Core/Base.js' + +/** + * @typedef {import('./Handler.js').default} SurfaceHandler + * + * @typedef {{ + * name: string + * last_page: number + * startup_page: number + * use_last_page: boolean + * }} SurfaceGroupConfig + */ + +export class SurfaceGroup extends CoreBase { + /** + * The defaults config for a group + * @type {SurfaceGroupConfig} + * @access public + * @static + */ + static DefaultOptions = { + name: 'Unnamed group', + last_page: 1, + startup_page: 1, + use_last_page: true, + } + + /** + * Id of this group + * @type {string} + * @access public + */ + groupId + + /** + * The current page of this surface group + * @type {number} + * @access private + */ + #currentPage = 1 + + /** + * The surfaces belonging to this group + * @type {SurfaceHandler[]} + * @access private + */ + surfaceHandlers = [] + + /** + * Whether this is an auto-group to wrap a single surface handler + * @type {boolean} + * @access private + */ + #isAutoGroup = false + + /** + * Whether surfaces in this group should be locked + * @type {boolean} + * @access private + */ + #isLocked = false + + /** + * Configuration of this surface group + * @type {SurfaceGroupConfig} + * @access public + */ + groupConfig + + /** + * + * @param {import('../Registry.js').default} registry + * @param {string} groupId + * @param {SurfaceHandler | null} soleHandler + * @param {boolean} isLocked + */ + constructor(registry, groupId, soleHandler, isLocked) { + super(registry, `group(${groupId})`, `Surface/Group/${groupId}`) + + this.groupId = groupId + this.#isLocked = isLocked + + // Load the appropriate config + if (soleHandler) { + this.groupConfig = soleHandler.getGroupConfig() ?? {} + if (!this.groupConfig.name) this.groupConfig.name = 'Auto group' + + this.#isAutoGroup = true + } else { + this.groupConfig = this.db.getKey('surface-groups', {})[this.groupId] || {} + } + // Apply missing defaults + this.groupConfig = { + ...cloneDeep(SurfaceGroup.DefaultOptions), + ...this.groupConfig, + } + + // Determine the correct page to use + if (this.groupConfig.use_last_page) { + this.#currentPage = this.groupConfig.last_page ?? 1 + } else { + this.#currentPage = this.groupConfig.last_page = this.groupConfig.startup_page ?? 1 + } + + // Now attach and setup the surface + if (soleHandler) this.attachSurface(soleHandler) + + this.#saveConfig() + } + + /** + * Stop anything processing this group, it is being marked as inactive + */ + dispose() { + // Nothing to do (yet) + } + + /** + * Delete this group from the config + */ + forgetConfig() { + const groupsConfig = this.db.getKey('surface-groups', {}) + delete groupsConfig[this.groupId] + this.db.setKey('surface-groups', groupsConfig) + } + + /** + * Check if this SurfaceGroup is an automatically generated group for a standalone surface + */ + get isAutoGroup() { + return this.#isAutoGroup + } + + /** + * Get the displayname of this surface group + */ + get displayName() { + const firstHandler = this.surfaceHandlers[0] + if (this.#isAutoGroup && firstHandler) { + return firstHandler.displayName + } else { + return this.groupConfig.name + } + } + + /** + * Add a surface to be run by this group + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + attachSurface(surfaceHandler) { + if (this.#isAutoGroup && this.surfaceHandlers.length) + throw new Error(`Cannot add surfaces to group: "${this.groupId}"`) + + this.surfaceHandlers.push(surfaceHandler) + + surfaceHandler.setLocked(this.#isLocked, true) + surfaceHandler.storeNewDevicePage(this.#currentPage, true) + } + + /** + * Detach a surface from this group + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + detachSurface(surfaceHandler) { + const surfaceId = surfaceHandler.surfaceId + this.surfaceHandlers = this.surfaceHandlers.filter((handler) => handler.surfaceId !== surfaceId) + } + + /** + * Perform page-down for this surface group + * @returns {void} + */ + doPageDown() { + if (this.userconfig.getKey('page_direction_flipped') === true) { + this.#increasePage() + } else { + this.#decreasePage() + } + } + + /** + * Set the current page of this surface group + * @param {number} newPage + * @param {boolean} defer + * @returns {void} + */ + setCurrentPage(newPage, defer = false) { + if (newPage == 100) { + newPage = 1 + } + if (newPage == 0) { + newPage = 99 + } + this.#storeNewPage(newPage, defer) + } + + /** + * Get the current page of this surface group + * @returns {number} + */ + getCurrentPage() { + return this.#currentPage + } + + /** + * Perform page-up for this surface group + * @returns {void} + */ + doPageUp() { + if (this.userconfig.getKey('page_direction_flipped') === true) { + this.#decreasePage() + } else { + this.#increasePage() + } + } + + #increasePage() { + let newPage = this.#currentPage + 1 + if (newPage >= 100) { + newPage = 1 + } + if (newPage <= 0) { + newPage = 99 + } + + this.#storeNewPage(newPage) + } + + #decreasePage() { + let newPage = this.#currentPage - 1 + if (newPage >= 100) { + newPage = 1 + } + if (newPage <= 0) { + newPage = 99 + } + + this.#storeNewPage(newPage) + } + + /** + * Update to a new page number + * @param {number} newPage + * @param {boolean} defer + * @returns {void} + */ + #storeNewPage(newPage, defer = false) { + // TODO - variables? + this.#currentPage = this.groupConfig.last_page = newPage + this.#saveConfig() + + for (const surfaceHandler of this.surfaceHandlers) { + surfaceHandler.storeNewDevicePage(newPage, defer) + } + } + + /** + * Update the config for this SurfaceGroup + * @param {string} key Config field to change + * @param {any} value New value for the field + * @returns + */ + setGroupConfigValue(key, value) { + this.logger.debug(`Set config "${key}" to "${value}"`) + switch (key) { + case 'use_last_page': { + value = Boolean(value) + + this.groupConfig.use_last_page = value + this.#saveConfig() + + return + } + case 'startup_page': { + value = Number(value) + if (isNaN(value)) { + this.logger.warn(`Invalid startup_page "${value}"`) + return 'invalid value' + } + + this.groupConfig.startup_page = value + this.#saveConfig() + + return + } + case 'last_page': { + value = Number(value) + if (isNaN(value)) { + this.logger.warn(`Invalid current_page "${value}"`) + return 'invalid value' + } + + this.#storeNewPage(value) + + return + } + default: + this.logger.warn(`Cannot set unknown config field "${key}"`) + return 'invalid key' + } + } + + /** + * Set the surface as locked + * @param {boolean} locked + * @returns {void} + */ + setLocked(locked) { + // // skip if surface can't be locked + // if (this.#surfaceConfig.config.never_lock) return + + // Track the locked status + this.#isLocked = !!locked + + // If it changed, redraw + for (const surface of this.surfaceHandlers) { + surface.setLocked(locked) + } + } + + /** + * Set the name of this surface group + * @param {string} name + * @returns {void} + */ + setName(name) { + this.groupConfig.name = name || 'Unnamed group' + this.#saveConfig() + } + + /** + * Save the configuration of this surface group + */ + #saveConfig() { + if (this.#isAutoGroup) { + // TODO: this does not feel great.. + const surface = this.surfaceHandlers[0] + surface.saveGroupConfig(this.groupConfig) + } else { + const groupsConfig = this.db.getKey('surface-groups', {}) + groupsConfig[this.groupId] = this.groupConfig + this.db.setKey('surface-groups', groupsConfig) + } + } +} diff --git a/lib/Surface/Handler.js b/lib/Surface/Handler.js index fe4454c28f..77cdab3557 100644 --- a/lib/Surface/Handler.js +++ b/lib/Surface/Handler.js @@ -20,6 +20,7 @@ import { oldBankIndexToXY } from '../Shared/ControlId.js' import { cloneDeep } from 'lodash-es' import { LEGACY_MAX_BUTTONS } from '../Util/Constants.js' import { rotateXYForPanel, unrotateXYForPanel } from './Util.js' +import { SurfaceGroup } from './Group.js' import { EventEmitter } from 'events' import { ImageResult } from '../Graphics/ImageResult.js' @@ -72,6 +73,8 @@ const PINCODE_NUMBER_POSITIONS_SKIP_FIRST_COL = [ * deviceId: string * devicePath: string * type: string + * configFields: string[] + * location?: string * }} SurfacePanelInfo * @typedef {{ * info: SurfacePanelInfo @@ -85,6 +88,16 @@ const PINCODE_NUMBER_POSITIONS_SKIP_FIRST_COL = [ * } & EventEmitter} SurfacePanel */ +/** + * Get the display name of a surface + * @param {Record} config + * @param {string} surfaceId + * @returns {string} + */ +export function getSurfaceName(config, surfaceId) { + return `${config?.name || config?.type || 'Unknown'} (${surfaceId})` +} + class SurfaceHandler extends CoreBase { static PanelDefaults = { // defaults from the panel - TODO properly @@ -92,11 +105,10 @@ class SurfaceHandler extends CoreBase { rotation: 0, // companion owned defaults - use_last_page: true, never_lock: false, - page: 1, xOffset: 0, yOffset: 0, + groupId: null, } /** @@ -111,7 +123,7 @@ class SurfaceHandler extends CoreBase { * @type {number} * @access private */ - #currentPage + #currentPage = 1 /** * Current pincode entry if locked @@ -179,15 +191,13 @@ class SurfaceHandler extends CoreBase { * @param {import('../Registry.js').default} registry * @param {string} integrationType * @param {SurfacePanel} panel - * @param {boolean} isLocked * @param {any | undefined} surfaceConfig */ - constructor(registry, integrationType, panel, isLocked, surfaceConfig) { + constructor(registry, integrationType, panel, surfaceConfig) { super(registry, `surface(${panel.info.deviceId})`, `Surface/Handler/${panel.info.deviceId}`) this.logger.silly('loading for ' + panel.info.devicePath) this.panel = panel - this.#isSurfaceLocked = isLocked this.#surfaceConfig = surfaceConfig ?? {} this.#pincodeNumberPositions = PINCODE_NUMBER_POSITIONS @@ -208,8 +218,6 @@ class SurfaceHandler extends CoreBase { this.#pincodeCodePosition = [3, 4] } - this.#currentPage = 1 // The current page of the surface - // Persist the type in the db for use when it is disconnected this.#surfaceConfig.type = this.panel.info.type || 'Unknown' this.#surfaceConfig.integrationType = integrationType @@ -227,23 +235,20 @@ class SurfaceHandler extends CoreBase { this.#surfaceConfig.config.yOffset = 0 } - if (this.#surfaceConfig.config.use_last_page === undefined) { + if (!this.#surfaceConfig.groupConfig) { // Fill in the new field based on previous behaviour: // If a page had been chosen, then it would start on that - this.#surfaceConfig.config.use_last_page = this.#surfaceConfig.config.page === undefined - } - - if (this.#surfaceConfig.config.use_last_page) { - if (this.#surfaceConfig.page !== undefined) { - // use last page if defined - this.#currentPage = this.#surfaceConfig.page - } - } else { - if (this.#surfaceConfig.config.page !== undefined) { - // use startup page if defined - this.#currentPage = this.#surfaceConfig.page = this.#surfaceConfig.config.page + const use_last_page = this.#surfaceConfig.config.use_last_page ?? this.#surfaceConfig.config.page === undefined + this.#surfaceConfig.groupConfig = { + page: this.#surfaceConfig.page, + startup_page: this.#surfaceConfig.config.page, + use_last_page: use_last_page, } } + // Forget old values + delete this.#surfaceConfig.config.use_last_page + delete this.#surfaceConfig.config.page + delete this.#surfaceConfig.page if (this.#surfaceConfig.config.never_lock) { // if device can't be locked, then make sure it isnt already locked @@ -269,12 +274,27 @@ class SurfaceHandler extends CoreBase { this.panel.setConfig(config, true) } - this.surfaces.emit('surface_page', this.surfaceId, this.#currentPage) - this.#drawPage() }) } + /** + * Get the current groupId this surface belongs to + * @returns {string | null} + */ + getGroupId() { + return this.#surfaceConfig.groupId + } + /** + * Set the current groupId of this surface + * @param {string | null} groupId + * @returns {void} + */ + setGroupId(groupId) { + this.#surfaceConfig.groupId = groupId + this.#saveConfig() + } + #getCurrentOffset() { return { xOffset: this.#surfaceConfig.config.xOffset, @@ -286,28 +306,8 @@ class SurfaceHandler extends CoreBase { return this.panel.info.deviceId } - #surfaceIncreasePage() { - this.#currentPage++ - if (this.#currentPage >= 100) { - this.#currentPage = 1 - } - if (this.#currentPage <= 0) { - this.#currentPage = 99 - } - - this.#storeNewDevicePage(this.#currentPage) - } - - #surfaceDecreasePage() { - this.#currentPage-- - if (this.#currentPage >= 100) { - this.#currentPage = 1 - } - if (this.#currentPage <= 0) { - this.#currentPage = 99 - } - - this.#storeNewDevicePage(this.#currentPage) + get displayName() { + return getSurfaceName(this.#surfaceConfig, this.surfaceId) } #drawPage() { @@ -368,9 +368,10 @@ class SurfaceHandler extends CoreBase { /** * Set the surface as locked * @param {boolean} locked + * @param {skipDraw=} locked * @returns {void} */ - setLocked(locked) { + setLocked(locked, skipDraw = false) { // skip if surface can't be locked if (this.#surfaceConfig.config.never_lock) return @@ -378,7 +379,9 @@ class SurfaceHandler extends CoreBase { if (this.#isSurfaceLocked != locked) { this.#isSurfaceLocked = !!locked - this.#drawPage() + if (!skipDraw) { + this.#drawPage() + } } } @@ -434,7 +437,12 @@ class SurfaceHandler extends CoreBase { #onDeviceRemove() { if (!this.panel) return - this.surfaces.removeDevice(this.panel.info.devicePath) + + try { + this.surfaces.removeDevice(this.panel.info.devicePath) + } catch (e) { + this.logger.error(`Remove failed: ${e}`) + } } #onDeviceResized() { @@ -452,7 +460,8 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ #onDeviceClick(x, y, pressed, pageOffset) { - if (this.panel) { + if (!this.panel) return + try { if (!this.#isSurfaceLocked) { this.emit('interaction') @@ -496,12 +505,9 @@ class SurfaceHandler extends CoreBase { } if (this.#currentPincodeEntry == this.userconfig.getKey('pin').toString()) { - this.#isSurfaceLocked = false this.#currentPincodeEntry = '' this.emit('unlocked') - - this.#drawPage() } else if (this.#currentPincodeEntry.length >= this.userconfig.getKey('pin').toString().length) { this.#currentPincodeEntry = '' } @@ -513,6 +519,8 @@ class SurfaceHandler extends CoreBase { this.panel.draw(this.#pincodeCodePosition[0], this.#pincodeCodePosition[1], datap.code) } } + } catch (e) { + this.logger.error(`Click failed: ${e}`) } } @@ -525,7 +533,8 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ #onDeviceRotate(x, y, direction, pageOffset) { - if (this.panel) { + if (!this.panel) return + try { if (!this.#isSurfaceLocked) { this.emit('interaction') @@ -553,6 +562,8 @@ class SurfaceHandler extends CoreBase { } else { // Ignore when locked out } + } catch (e) { + this.logger.error(`Click failed: ${e}`) } } @@ -608,49 +619,50 @@ class SurfaceHandler extends CoreBase { } } - doPageDown() { - if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#surfaceIncreasePage() - } else { - this.#surfaceDecreasePage() - } + /** + * Reset the config of this surface to defaults + */ + resetConfig() { + this.#surfaceConfig.groupConfig = cloneDeep(SurfaceGroup.DefaultOptions) + this.#surfaceConfig.groupId = null + this.setPanelConfig(cloneDeep(SurfaceHandler.PanelDefaults)) } /** - * - * @param {number} page - * @param {boolean} defer - * @returns {void} + * Trigger a save of the config */ - setCurrentPage(page, defer = false) { - this.#currentPage = page - if (this.#currentPage == 100) { - this.#currentPage = 1 - } - if (this.#currentPage == 0) { - this.#currentPage = 99 - } - this.#storeNewDevicePage(this.#currentPage, defer) + #saveConfig() { + this.emit('configUpdated', this.#surfaceConfig) } - getCurrentPage() { - return this.#currentPage - } + /** + * Get the 'SurfaceGroup' config for this surface, when run as an auto group + * @returns {import('./Group.js').SurfaceGroupConfig} + */ + getGroupConfig() { + if (this.getGroupId()) throw new Error('Cannot retrieve the config from a non-auto surface') - doPageUp() { - if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#surfaceDecreasePage() - } else { - this.#surfaceIncreasePage() - } + return this.#surfaceConfig.groupConfig } - resetConfig() { - this.setPanelConfig(cloneDeep(SurfaceHandler.PanelDefaults)) + /** + * Get the full config blob for this surface + * @returns {any} + */ + getFullConfig() { + return this.#surfaceConfig } - #saveConfig() { - this.emit('configUpdated', this.#surfaceConfig) + /** + * Set and save the 'SurfaceGroup' config for this surface, when run as an auto group + * @param {import('./Group.js').SurfaceGroupConfig} groupConfig + * @returns {void} + */ + saveGroupConfig(groupConfig) { + if (this.getGroupId()) throw new Error('Cannot save the config for a non-auto surface') + + this.#surfaceConfig.groupConfig = groupConfig + this.#saveConfig() } /** @@ -659,15 +671,6 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ setPanelConfig(newconfig) { - if ( - !newconfig.use_last_page && - newconfig.page !== undefined && - newconfig.page !== this.#surfaceConfig.config.page - ) { - // Startup page has changed, so change over to it - this.#storeNewDevicePage(newconfig.page) - } - let redraw = false if ( newconfig.xOffset != this.#surfaceConfig.config.xOffset || @@ -713,9 +716,8 @@ class SurfaceHandler extends CoreBase { * @param {boolean} defer * @returns {void} */ - #storeNewDevicePage(newpage, defer = false) { - this.#surfaceConfig.page = this.#currentPage = newpage - this.#saveConfig() + storeNewDevicePage(newpage, defer = false) { + this.#currentPage = newpage this.surfaces.emit('surface_page', this.surfaceId, newpage) diff --git a/webui/src/Controls/InternalInstanceFields.jsx b/webui/src/Controls/InternalInstanceFields.jsx index d243182c36..7b7080dad9 100644 --- a/webui/src/Controls/InternalInstanceFields.jsx +++ b/webui/src/Controls/InternalInstanceFields.jsx @@ -52,6 +52,7 @@ export function InternalInstanceField(option, isOnControl, readonly, value, setV value={value} setValue={setValue} includeSelf={option.includeSelf} + useRawSurfaces={option.useRawSurfaces} /> ) case 'internal:trigger': @@ -181,8 +182,8 @@ function InternalVariableDropdown({ value, setValue, disabled }) { ) } -function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf }) { - const context = useContext(SurfacesContext) +function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf, useRawSurfaces }) { + const surfacesContext = useContext(SurfacesContext) const choices = useMemo(() => { const choices = [] @@ -190,21 +191,26 @@ function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disable choices.push({ id: 'self', label: 'Current surface' }) } - for (const surface of Object.values(context?.available ?? {})) { - choices.push({ - label: `${surface.name || surface.type} (${surface.id})`, - id: surface.id, - }) + if (!useRawSurfaces) { + for (const group of surfacesContext ?? []) { + choices.push({ + label: group.displayName, + id: group.id, + }) + } + } else { + for (const group of surfacesContext ?? []) { + for (const surface of group.surfaces) { + choices.push({ + label: surface.displayName, + id: surface.id, + }) + } + } } - for (const surface of Object.values(context?.offline ?? {})) { - choices.push({ - label: `${surface.name || surface.type} (${surface.id}) - Offline`, - id: surface.id, - }) - } return choices - }, [context, isOnControl, includeSelf]) + }, [surfacesContext, isOnControl, includeSelf, useRawSurfaces]) return } diff --git a/webui/src/Surfaces/AddGroupModal.jsx b/webui/src/Surfaces/AddGroupModal.jsx new file mode 100644 index 0000000000..1d9de27890 --- /dev/null +++ b/webui/src/Surfaces/AddGroupModal.jsx @@ -0,0 +1,79 @@ +import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' +import { + CButton, + CForm, + CFormGroup, + CInput, + CLabel, + CModal, + CModalBody, + CModalFooter, + CModalHeader, +} from '@coreui/react' +import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' + +export const AddSurfaceGroupModal = forwardRef(function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + + const [show, setShow] = useState(false) + + const [groupName, setGroupName] = useState(null) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setGroupName(null) + }, []) + + const doAction = useCallback( + (e) => { + if (e) e.preventDefault() + + if (!groupName) return + + setShow(false) + setGroupName(null) + + socketEmitPromise(socket, 'surfaces:group-add', [groupName]).catch((err) => { + console.error('Group add failed', err) + }) + }, + [groupName] + ) + + useImperativeHandle( + ref, + () => ({ + show() { + setShow(true) + setGroupName('My group') + }, + }), + [] + ) + + const onNameChange = useCallback((e) => setGroupName(e.target.value), []) + + return ( + + +
Add Surface Group
+
+ + + + Name + + + + + + + Cancel + + + Save + + +
+ ) +}) diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx index ff0d686736..be17288a04 100644 --- a/webui/src/Surfaces/EditModal.jsx +++ b/webui/src/Surfaces/EditModal.jsx @@ -12,75 +12,135 @@ import { CModalHeader, CSelect, } from '@coreui/react' -import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' +import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler, SurfacesContext } from '../util' import { nanoid } from 'nanoid' +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { InternalInstanceField } from '../Controls/InternalInstanceFields' +import { MenuPortalContext } from '../Components/DropdownInputField' + +const PAGE_FIELD_SPEC = { type: 'internal:page', includeDirection: false } export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { const socket = useContext(SocketContext) + const surfacesContext = useContext(SurfacesContext) - const [surfaceInfo, setSurfaceInfo] = useState(null) + const [rawGroupId, setGroupId] = useState(null) + const [surfaceId, setSurfaceId] = useState(null) const [show, setShow] = useState(false) + let surfaceInfo = null + if (surfaceId) { + for (const group of surfacesContext) { + if (surfaceInfo) break + + for (const surface of group.surfaces) { + if (surface.id === surfaceId) { + surfaceInfo = { + ...surface, + groupId: group.isAutoGroup ? null : group.id, + } + break + } + } + } + } + + const groupId = surfaceInfo && !surfaceInfo.groupId ? surfaceId : rawGroupId + let groupInfo = null + if (groupId) { + for (const group of surfacesContext) { + if (group.id === groupId) { + groupInfo = group + break + } + } + } + const [surfaceConfig, setSurfaceConfig] = useState(null) - const [surfaceConfigError, setSurfaceConfigError] = useState(null) + const [groupConfig, setGroupConfig] = useState(null) + const [configLoadError, setConfigLoadError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doClose = useCallback(() => setShow(false), []) const onClosed = useCallback(() => { - setSurfaceInfo(null) + setSurfaceId(null) setSurfaceConfig(null) - setSurfaceConfigError(null) + setConfigLoadError(null) }, []) const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) useEffect(() => { - setSurfaceConfigError(null) + setConfigLoadError(null) setSurfaceConfig(null) + setGroupConfig(null) - if (surfaceInfo?.id) { - socketEmitPromise(socket, 'surfaces:config-get', [surfaceInfo.id]) + if (surfaceId) { + socketEmitPromise(socket, 'surfaces:config-get', [surfaceId]) .then((config) => { - console.log(config) setSurfaceConfig(config) }) .catch((err) => { console.error('Failed to load surface config') - setSurfaceConfigError(`Failed to load surface config`) + setConfigLoadError(`Failed to load surface config`) }) } - }, [socket, surfaceInfo?.id, reloadToken]) + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-get', [groupId]) + .then((config) => { + setGroupConfig(config) + }) + .catch((err) => { + console.error('Failed to load group config') + setConfigLoadError(`Failed to load surface group config`) + }) + } + }, [socket, surfaceId, groupId, reloadToken]) useImperativeHandle( ref, () => ({ - show(surface) { - setSurfaceInfo(surface) + show(surfaceId, groupId) { + setSurfaceId(surfaceId) + setGroupId(groupId) setShow(true) }, - ensureIdIsValid(surfaceIds) { - setSurfaceInfo((oldSurface) => { - if (oldSurface && surfaceIds.indexOf(oldSurface.id) === -1) { - setShow(false) - } - return oldSurface - }) - }, }), [] ) - const updateConfig = useCallback( + useEffect(() => { + // If surface disappears/disconnects, hide this + + const onlineSurfaceIds = new Set() + for (const group of surfacesContext) { + for (const surface of group.surfaces) { + if (surface.isConnected) { + onlineSurfaceIds.add(surface.id) + } + } + } + + setSurfaceId((oldSurfaceId) => { + if (oldSurfaceId && !onlineSurfaceIds.has(oldSurfaceId)) { + setShow(false) + } + return oldSurfaceId + }) + }, [surfacesContext]) + + const setSurfaceConfigValue = useCallback( (key, value) => { - console.log('update', key, value) - if (surfaceInfo?.id) { + console.log('update surface', key, value) + if (surfaceId) { setSurfaceConfig((oldConfig) => { const newConfig = { ...oldConfig, [key]: value, } - socketEmitPromise(socket, 'surfaces:config-set', [surfaceInfo.id, newConfig]) + socketEmitPromise(socket, 'surfaces:config-set', [surfaceId, newConfig]) .then((newConfig) => { if (typeof newConfig === 'string') { console.log('Config update failed', newConfig) @@ -95,197 +155,280 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref }) } }, - [socket, surfaceInfo?.id] + [socket, surfaceId] + ) + const setGroupConfigValue = useCallback( + (key, value) => { + console.log('update group', key, value) + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-set', [groupId, key, value]) + .then((newConfig) => { + if (typeof newConfig === 'string') { + console.log('group config update failed', newConfig) + } else { + setGroupConfig(newConfig) + } + }) + .catch((e) => { + console.log('group config update failed', e) + }) + + setGroupConfig((oldConfig) => { + return { + ...oldConfig, + [key]: value, + } + }) + } + }, + [socket, groupId] + ) + + const setSurfaceGroupId = useCallback( + (groupId) => { + if (!groupId || groupId === 'null') groupId = null + socketEmitPromise(socket, 'surfaces:add-to-group', [groupId, surfaceId]).catch((e) => { + console.log('Config update failed', e) + }) + }, + [socket, surfaceId] ) + const [modalRef, setModalRef] = useState(null) + return ( - - -
Settings for {surfaceInfo?.type}
-
- - - {surfaceConfig && surfaceInfo && ( + + + +
Settings for {surfaceInfo?.displayName ?? surfaceInfo?.type ?? groupInfo?.displayName}
+
+ + + - - Use Last Page At Startup - updateConfig('use_last_page', !!e.currentTarget.checked)} - /> - - - Startup Page - updateConfig('page', parseInt(e.currentTarget.value))} - /> - {surfaceConfig.page} - - {surfaceInfo.configFields?.includes('emulator_size') && ( + {surfaceInfo && ( + + + Surface Group  + + + setSurfaceGroupId(e.currentTarget.value)} + > + + + {surfacesContext + .filter((group) => !group.isAutoGroup) + .map((group) => ( + + ))} + + + )} + + {groupConfig && ( + <> + + Use Last Page At Startup + setGroupConfigValue('use_last_page', !!e.currentTarget.checked)} + /> + + + Startup Page + + {InternalInstanceField( + PAGE_FIELD_SPEC, + false, + !!groupConfig.use_last_page, + groupConfig.startup_page, + (val) => setGroupConfigValue('startup_page', val) + )} + + + Current Page + + {InternalInstanceField(PAGE_FIELD_SPEC, false, false, groupConfig.last_page, (val) => + setGroupConfigValue('last_page', val) + )} + + + )} + + {surfaceConfig && surfaceInfo && ( <> + {surfaceInfo.configFields?.includes('emulator_size') && ( + <> + + Row count + setSurfaceConfigValue('emulator_rows', parseInt(e.currentTarget.value))} + /> + + + Column count + setSurfaceConfigValue('emulator_columns', parseInt(e.currentTarget.value))} + /> + + + )} + - Row count + Horizontal Offset in grid updateConfig('emulator_rows', parseInt(e.currentTarget.value))} + value={surfaceConfig.xOffset} + onChange={(e) => setSurfaceConfigValue('xOffset', parseInt(e.currentTarget.value))} /> - Column count + Vertical Offset in grid updateConfig('emulator_columns', parseInt(e.currentTarget.value))} + value={surfaceConfig.yOffset} + onChange={(e) => setSurfaceConfigValue('yOffset', parseInt(e.currentTarget.value))} /> - - )} - - Horizontal Offset in grid - updateConfig('xOffset', parseInt(e.currentTarget.value))} - /> - - - Vertical Offset in grid - updateConfig('yOffset', parseInt(e.currentTarget.value))} - /> - - - {surfaceInfo.configFields?.includes('brightness') && ( - - Brightness - updateConfig('brightness', parseInt(e.currentTarget.value))} - /> - - )} - {surfaceInfo.configFields?.includes('illuminate_pressed') && ( - - Illuminate pressed buttons - updateConfig('illuminate_pressed', !!e.currentTarget.checked)} - /> - - )} + {surfaceInfo.configFields?.includes('brightness') && ( + + Brightness + setSurfaceConfigValue('brightness', parseInt(e.currentTarget.value))} + /> + + )} + {surfaceInfo.configFields?.includes('illuminate_pressed') && ( + + Illuminate pressed buttons + setSurfaceConfigValue('illuminate_pressed', !!e.currentTarget.checked)} + /> + + )} - - Button rotation - { - const valueNumber = parseInt(e.currentTarget.value) - updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) - }} - > - - - - + + Button rotation + { + const valueNumber = parseInt(e.currentTarget.value) + setSurfaceConfigValue('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) + }} + > + + + + - {surfaceInfo.configFields?.includes('legacy_rotation') && ( - <> - - - - + {surfaceInfo.configFields?.includes('legacy_rotation') && ( + <> + + + + + )} + + + {surfaceInfo.configFields?.includes('emulator_control_enable') && ( + + Enable support for Logitech R400/Mastercue/DSan + setSurfaceConfigValue('emulator_control_enable', !!e.currentTarget.checked)} + /> + )} - - - {surfaceInfo.configFields?.includes('emulator_control_enable') && ( - - Enable support for Logitech R400/Mastercue/DSan - updateConfig('emulator_control_enable', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( - - Prompt to enter fullscreen - updateConfig('emulator_prompt_fullscreen', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('videohub_page_count') && ( - - Page Count - updateConfig('videohub_page_count', parseInt(e.currentTarget.value))} - /> - + {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( + + Prompt to enter fullscreen + setSurfaceConfigValue('emulator_prompt_fullscreen', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('videohub_page_count') && ( + + Page Count + setSurfaceConfigValue('videohub_page_count', parseInt(e.currentTarget.value))} + /> + + )} + + Never Pin code lock + setSurfaceConfigValue('never_lock', !!e.currentTarget.checked)} + /> + + )} - - Never Pin code lock - updateConfig('never_lock', !!e.currentTarget.checked)} - /> - - )} - - - - Close - - +
+ + + Close + + +
) }) diff --git a/webui/src/Surfaces/index.jsx b/webui/src/Surfaces/index.jsx index 0fb9e879e0..c543809c58 100644 --- a/webui/src/Surfaces/index.jsx +++ b/webui/src/Surfaces/index.jsx @@ -1,61 +1,27 @@ -import React, { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useRef, useState } from 'react' import { CAlert, CButton, CButtonGroup } from '@coreui/react' import { SurfacesContext, socketEmitPromise, SocketContext } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAdd, faCog, faFolderOpen, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' import { TextInputField } from '../Components/TextInputField' -import { useMemo } from 'react' import { GenericConfirmModal } from '../Components/GenericConfirmModal' import { SurfaceEditModal } from './EditModal' +import { AddSurfaceGroupModal } from './AddGroupModal' +import classNames from 'classnames' export const SurfacesPage = memo(function SurfacesPage() { const socket = useContext(SocketContext) - const surfaces = useContext(SurfacesContext) + const surfacesContext = useContext(SurfacesContext) const confirmRef = useRef(null) - const surfacesList = useMemo(() => { - const ary = Object.values(surfaces.available) - - ary.sort((a, b) => { - if (a.index !== b.index) { - return a.index - b.index - } - - // fallback to serial - return a.id.localeCompare(b.id) - }) - - return ary - }, [surfaces.available]) - const offlineSurfacesList = useMemo(() => { - const ary = Object.values(surfaces.offline) - - ary.sort((a, b) => { - if (a.index !== b.index) { - return a.index - b.index - } - - // fallback to serial - return a.id.localeCompare(b.id) - }) - - return ary - }, [surfaces.offline]) - - const editModalRef = useRef() + const editModalRef = useRef(null) + const addGroupModalRef = useRef(null) const confirmModalRef = useRef(null) const [scanning, setScanning] = useState(false) const [scanError, setScanError] = useState(null) - useEffect(() => { - // If surface disappears, hide the edit modal - if (editModalRef.current) { - editModalRef.current.ensureIdIsValid(Object.keys(surfaces)) - } - }, [surfaces]) - const refreshUSB = useCallback(() => { setScanning(true) setScanError(null) @@ -89,8 +55,27 @@ export const SurfacesPage = memo(function SurfacesPage() { [socket] ) - const configureSurface = useCallback((surface) => { - editModalRef.current.show(surface) + const addGroup = useCallback(() => { + addGroupModalRef.current.show() + }, [socket]) + + const deleteGroup = useCallback( + (groupId) => { + confirmRef?.current?.show('Remove Group', 'Are you sure?', 'Remove', () => { + socketEmitPromise(socket, 'surfaces:group-remove', [groupId]).catch((err) => { + console.error('Group remove failed', err) + }) + }) + }, + [socket] + ) + + const configureSurface = useCallback((surfaceId) => { + editModalRef.current.show(surfaceId, null) + }, []) + + const configureGroup = useCallback((groupId) => { + editModalRef.current.show(null, groupId) }, []) const forgetSurface = useCallback( @@ -148,16 +133,16 @@ export const SurfacesPage = memo(function SurfacesPage() { Add Emulator + + Add Group + -

 

- + -
Connected
- -
You haven't setup any connections yet.
@@ -219,13 +225,13 @@ function loadVisibility() { return config } -function InstancesTableRow({ +function ConnectionsTableRow({ id, - instance, - instanceStatus, + connection, + connectionStatus, showHelp, showVariables, - configureInstance, + configureConnection, deleteModalRef, moveRow, isSelected, @@ -234,33 +240,33 @@ function InstancesTableRow({ const modules = useContext(ModulesContext) const variableDefinitionsContext = useContext(VariableDefinitionsContext) - const moduleInfo = modules[instance.instance_type] + const moduleInfo = modules[connection.instance_type] - const isEnabled = instance.enabled === undefined || instance.enabled + const isEnabled = connection.enabled === undefined || connection.enabled const doDelete = useCallback(() => { deleteModalRef.current.show( 'Delete connection', - `Are you sure you want to delete "${instance.label}"?`, + `Are you sure you want to delete "${connection.label}"?`, 'Delete', () => { - socketEmitPromise(socket, 'instances:delete', [id]).catch((e) => { + socketEmitPromise(socket, 'connections:delete', [id]).catch((e) => { console.error('Delete failed', e) }) - configureInstance(null) + configureConnection(null) } ) - }, [socket, deleteModalRef, id, instance.label, configureInstance]) + }, [socket, deleteModalRef, id, connection.label, configureConnection]) const doToggleEnabled = useCallback(() => { - socketEmitPromise(socket, 'instances:set-enabled', [id, !isEnabled]).catch((e) => { + socketEmitPromise(socket, 'connections:set-enabled', [id, !isEnabled]).catch((e) => { console.error('Set enabled failed', e) }) }, [socket, id, isEnabled]) - const doShowHelp = useCallback(() => showHelp(instance.instance_type), [showHelp, instance.instance_type]) + const doShowHelp = useCallback(() => showHelp(connection.instance_type), [showHelp, connection.instance_type]) - const doShowVariables = useCallback(() => showVariables(instance.label), [showVariables, instance.label]) + const doShowVariables = useCallback(() => showVariables(connection.label), [showVariables, connection.label]) const ref = useRef(null) const [, drop] = useDrop({ @@ -289,30 +295,30 @@ function InstancesTableRow({ }) preview(drop(ref)) - const instanceVariables = variableDefinitionsContext[instance.label] + const connectionVariables = variableDefinitionsContext[connection.label] const doEdit = () => { if (!moduleInfo || !isEnabled) { return } - configureInstance(id) + configureConnection(id) } return (
- {instance.label} + {connection.label} {moduleInfo ? ( @@ -332,10 +338,10 @@ function InstancesTableRow({ {moduleInfo?.manufacturer ?? ''} ) : ( - instance.instance_type + connection.instance_type )}
@@ -366,9 +372,10 @@ function InstancesTableRow({ size="md" style={{ padding: 4, - opacity: !isEnabled || !(instanceVariables && Object.keys(instanceVariables).length > 0) ? 0.2 : 1, + opacity: + !isEnabled || !(connectionVariables && Object.keys(connectionVariables).length > 0) ? 0.2 : 1, }} - disabled={!isEnabled || !(instanceVariables && Object.keys(instanceVariables).length > 0)} + disabled={!isEnabled || !(connectionVariables && Object.keys(connectionVariables).length > 0)} > @@ -420,7 +427,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) case 'warning': return ( -
+ {status.level || 'Warning'}
{messageStr} @@ -428,7 +435,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) case 'error': return ( -
+ {status.level || 'ERROR'}
{messageStr} @@ -436,7 +443,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) default: return ( -
+ Unknown
{messageStr} diff --git a/webui/src/Instances/InstanceVariablesModal.jsx b/webui/src/Connections/ConnectionVariablesModal.jsx similarity index 70% rename from webui/src/Instances/InstanceVariablesModal.jsx rename to webui/src/Connections/ConnectionVariablesModal.jsx index eb45729f1f..8a3515de35 100644 --- a/webui/src/Instances/InstanceVariablesModal.jsx +++ b/webui/src/Connections/ConnectionVariablesModal.jsx @@ -2,18 +2,18 @@ import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'r import { CModal, CModalBody, CModalHeader, CModalFooter, CButton, CRow, CCol } from '@coreui/react' import { VariablesTable } from '../Components/VariablesTable' -export const InstanceVariablesModal = forwardRef(function HelpModal(_props, ref) { - const [instanceLabel, setInstanceLabel] = useState(null) +export const ConnectionVariablesModal = forwardRef(function HelpModal(_props, ref) { + const [connectionLabel, setConnectionLabel] = useState(null) const [show, setShow] = useState(false) const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setInstanceLabel(null), []) + const onClosed = useCallback(() => setConnectionLabel(null), []) useImperativeHandle( ref, () => ({ show(label) { - setInstanceLabel(label) + setConnectionLabel(label) setShow(true) }, }), @@ -23,12 +23,12 @@ export const InstanceVariablesModal = forwardRef(function HelpModal(_props, ref) return ( -
Variables for {instanceLabel}
+
Variables for {connectionLabel}
- + diff --git a/webui/src/Instances/HelpModal.jsx b/webui/src/Connections/HelpModal.jsx similarity index 100% rename from webui/src/Instances/HelpModal.jsx rename to webui/src/Connections/HelpModal.jsx diff --git a/webui/src/Instances/index.jsx b/webui/src/Connections/index.jsx similarity index 60% rename from webui/src/Instances/index.jsx rename to webui/src/Connections/index.jsx index 9cf80cc336..49afd26313 100644 --- a/webui/src/Instances/index.jsx +++ b/webui/src/Connections/index.jsx @@ -2,16 +2,16 @@ import { CCol, CRow, CTabs, CTabContent, CTabPane, CNavItem, CNavLink, CNav } fr import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' import { HelpModal } from './HelpModal' import { NotifierContext, MyErrorBoundary, socketEmitPromise, SocketContext } from '../util' -import { InstancesList } from './InstanceList' -import { AddInstancesPanel } from './AddInstance' -import { InstanceEditPanel } from './InstanceEditPanel' +import { ConnectionsList } from './ConnectionList' +import { AddConnectionsPanel } from './AddConnection' +import { ConnectionEditPanel } from './ConnectionEditPanel' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { nanoid } from 'nanoid' import { faCog, faPlus } from '@fortawesome/free-solid-svg-icons' import jsonPatch from 'fast-json-patch' import { cloneDeep } from 'lodash-es' -export const InstancesPage = memo(function InstancesPage() { +export const ConnectionsPage = memo(function ConnectionsPage() { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) @@ -19,11 +19,11 @@ export const InstancesPage = memo(function InstancesPage() { const [tabResetToken, setTabResetToken] = useState(nanoid()) const [activeTab, setActiveTab] = useState('add') - const [selectedInstanceId, setSelectedInstanceId] = useState(null) + const [selectedConnectionId, setSelectedConnectionId] = useState(null) const doChangeTab = useCallback((newTab) => { setActiveTab((oldTab) => { if (oldTab !== newTab) { - setSelectedInstanceId(null) + setSelectedConnectionId(null) setTabResetToken(nanoid()) } return newTab @@ -32,7 +32,7 @@ export const InstancesPage = memo(function InstancesPage() { const showHelp = useCallback( (id) => { - socketEmitPromise(socket, 'instances:get-help', [id]).then(([err, result]) => { + socketEmitPromise(socket, 'connections:get-help', [id]).then(([err, result]) => { if (err) { notifier.current.show('Instance help', `Failed to get help text: ${err}`) return @@ -45,49 +45,49 @@ export const InstancesPage = memo(function InstancesPage() { [socket, notifier] ) - const doConfigureInstance = useCallback((id) => { - setSelectedInstanceId(id) + const doConfigureConnection = useCallback((id) => { + setSelectedConnectionId(id) setTabResetToken(nanoid()) setActiveTab(id ? 'edit' : 'add') }, []) - const [instanceStatus, setInstanceStatus] = useState(null) + const [connectionStatus, setConnectionStatus] = useState(null) useEffect(() => { - socketEmitPromise(socket, 'instance_status:get', []) + socketEmitPromise(socket, 'connections:get-statuses', []) .then((statuses) => { - setInstanceStatus(statuses) + setConnectionStatus(statuses) }) .catch((e) => { - console.error(`Failed to load instance statuses`, e) + console.error(`Failed to load connection statuses`, e) }) const patchStatuses = (patch) => { - setInstanceStatus((oldStatuses) => { + setConnectionStatus((oldStatuses) => { if (!oldStatuses) return oldStatuses return jsonPatch.applyPatch(cloneDeep(oldStatuses) || {}, patch).newDocument }) } - socket.on('instance_status:patch', patchStatuses) + socket.on('connections:patch-statuses', patchStatuses) return () => { - socket.off('instance_status:patch', patchStatuses) + socket.off('connections:patch-statuses', patchStatuses) } }, [socket]) return ( - + - - + - +
@@ -96,7 +96,7 @@ export const InstancesPage = memo(function InstancesPage() { Add connection -
diff --git a/webui/src/scss/_instances.scss b/webui/src/scss/_instances.scss index 9020d82d7f..dbb5fe03a2 100644 --- a/webui/src/scss/_instances.scss +++ b/webui/src/scss/_instances.scss @@ -17,11 +17,11 @@ } } -.instancelist-dragging { +.connectionlist-dragging { opacity: 0.5; } -.instancelist-selected { +.connectionlist-selected { background-color: rgba(200, 200, 200, 0.35); &:hover { @@ -29,7 +29,7 @@ } } -.instancelist-notdragging:hover { +.connectionlist-notdragging:hover { background-color: rgba(200, 200, 200, 0.1); } @@ -42,27 +42,27 @@ } } -.instance-status-ok { +.connection-status-ok { background-color: #cfc; } -.instance-status-warn { +.connection-status-warn { background-color: #fea; } -.instance-status-error { +.connection-status-error { background-color: #fcc; } -#instance_add_search_results { +#connection_add_search_results { margin-top: 10px; } -#instance_add_search_results div { +#connection_add_search_results div { margin-bottom: 5px; } -.edit-instance { +.edit-connection { & > div { // column margin-bottom: 10px; @@ -127,7 +127,7 @@ margin: 0 0 1rem 0; } -.instances-panel { +.connections-panel { .action-buttons { .btn-primary.disabled { opacity: 0.5; diff --git a/webui/src/util.jsx b/webui/src/util.jsx index 85f6fb210e..6449e40c0c 100644 --- a/webui/src/util.jsx +++ b/webui/src/util.jsx @@ -14,7 +14,7 @@ export const NotifierContext = React.createContext(null) export const ModulesContext = React.createContext(null) export const ActionsContext = React.createContext(null) export const FeedbacksContext = React.createContext(null) -export const InstancesContext = React.createContext(null) +export const ConnectionsContext = React.createContext(null) export const VariableDefinitionsContext = React.createContext(null) export const CustomVariableDefinitionsContext = React.createContext(null) export const UserConfigContext = React.createContext(null) From c1d46c50fc52edb43d71e494c874432c7b877102 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 13:54:20 +0000 Subject: [PATCH 19/53] chore: continue rename instance to connection --- lib/Controls/ActionRecorder.js | 64 +++++++++--------- lib/Controls/ControlBase.js | 6 +- lib/Controls/ControlTypes/Button/Base.js | 26 ++++---- lib/Controls/ControlTypes/Triggers/Trigger.js | 26 ++++---- lib/Controls/Controller.js | 44 ++++++------- lib/Controls/Fragments/FragmentActions.js | 18 ++--- lib/Controls/Fragments/FragmentFeedbacks.js | 44 ++++++------- lib/Controls/IControlFragments.js | 24 +++---- lib/Instance/Controller.js | 14 ++-- lib/Instance/Definitions.js | 36 +++++----- lib/Instance/Host.js | 4 +- lib/Instance/Status.js | 2 +- lib/Instance/Variable.js | 8 +-- lib/Internal/ActionRecorder.js | 2 +- lib/Registry.js | 2 +- webui/src/Buttons/ActionRecorder.jsx | 16 ++--- webui/src/Buttons/ButtonInfiniteGrid.jsx | 2 +- webui/src/Buttons/Presets.jsx | 66 +++++++++---------- webui/src/Buttons/Variables.jsx | 48 +++++++------- webui/src/Buttons/index.jsx | 4 +- webui/src/Controls/ActionSetEditor.jsx | 28 ++++---- webui/src/Controls/FeedbackEditor.jsx | 20 +++--- webui/src/Controls/OptionsInputField.jsx | 6 +- webui/src/ImportExport/Import/Page.jsx | 2 +- webui/src/Triggers/EventEditor.jsx | 2 +- webui/src/scss/_layout.scss | 2 +- 26 files changed, 259 insertions(+), 257 deletions(-) diff --git a/lib/Controls/ActionRecorder.js b/lib/Controls/ActionRecorder.js index c291d56ec2..f1535eb9e7 100644 --- a/lib/Controls/ActionRecorder.js +++ b/lib/Controls/ActionRecorder.js @@ -76,12 +76,12 @@ export default class ActionRecorder extends EventEmitter { #registry /** - * The instance ids which are currently informed to be recording + * The connection ids which are currently informed to be recording * Note: this may contain some ids which are not, * @type {Set} * @access private */ - #currentlyRecordingInstanceIds = new Set() + #currentlyRecordingConnectionIds = new Set() /** * Data from the current recording session @@ -190,12 +190,12 @@ export default class ActionRecorder extends EventEmitter { } ) client.onPromise( - 'action-recorder:session:set-instances', + 'action-recorder:session:set-connections', (/** @type {string} */ sessionId, /** @type {string[]} */ connectionIds) => { if (!this.#currentSession || this.#currentSession.id !== sessionId) throw new Error(`Invalid session: ${sessionId}`) - this.setSelectedInstanceIds(connectionIds) + this.setSelectedConnectionIds(connectionIds) return true } @@ -406,15 +406,15 @@ export default class ActionRecorder extends EventEmitter { } /** - * An instance has just started/stopped, make sure it is aware if it should be recording - * @param {string} instanceId + * An conncetion has just started/stopped, make sure it is aware if it should be recording + * @param {string} connectionId * @param {boolean} running Whether it is now running */ - instanceAvailabilityChange(instanceId, running) { + connectionAvailabilityChange(connectionId, running) { if (!running) { if (this.#currentSession) { - // Remove the instance which has stopped - const newIds = this.#currentSession.connectionIds.filter((id) => id !== instanceId) + // Remove the connection which has stopped + const newIds = this.#currentSession.connectionIds.filter((id) => id !== connectionId) if (newIds.length !== this.#currentSession.connectionIds.length) { this.commitChanges([this.#currentSession.id]) @@ -424,24 +424,24 @@ export default class ActionRecorder extends EventEmitter { } /** - * Add an action received from an instance to the session + * Add an action received from a connection to the session * @access public - * @param {string} instanceId + * @param {string} connectionId * @param {string} actionId * @param {Record} options * @param {string | undefined} uniquenessId */ - receiveAction(instanceId, actionId, options, uniquenessId) { + receiveAction(connectionId, actionId, options, uniquenessId) { const changedSessionIds = [] if (this.#currentSession) { const session = this.#currentSession - if (session.connectionIds.includes(instanceId)) { + if (session.connectionIds.includes(connectionId)) { /** @type {RecordActionTmp} */ const newAction = { id: nanoid(), - instance: instanceId, + instance: connectionId, action: actionId, options: options, delay: session.actionDelay ?? 0, @@ -510,11 +510,11 @@ export default class ActionRecorder extends EventEmitter { } /** - * Set the current instances being recorded from + * Set the current connections being recorded from * @param {Array} connectionIds0 */ - setSelectedInstanceIds(connectionIds0) { - if (!Array.isArray(connectionIds0)) throw new Error('Expected array of instance ids') + setSelectedConnectionIds(connectionIds0) { + if (!Array.isArray(connectionIds0)) throw new Error('Expected array of connection ids') const allValidIds = new Set(this.#registry.instance.getAllInstanceIds()) const connectionIds = connectionIds0.filter((id) => allValidIds.has(id)) @@ -525,47 +525,47 @@ export default class ActionRecorder extends EventEmitter { } /** - * Sync the correct recording status to each instance + * Sync the correct recording status to each connection * @access private */ #syncRecording() { const ps = [] - const targetRecordingInstanceIds = new Set() + const targetRecordingConnectionIds = new Set() if (this.#currentSession && this.#currentSession.isRunning) { for (const id of this.#currentSession.connectionIds) { - targetRecordingInstanceIds.add(id) + targetRecordingConnectionIds.add(id) } } // Find ones to start recording - for (const instanceId of targetRecordingInstanceIds.values()) { + for (const connectionId of targetRecordingConnectionIds.values()) { // Future: skip checking if they already know, to make sure they dont get stuck - const instance = this.#registry.instance.moduleHost.getChild(instanceId) - if (instance) { + const connection = this.#registry.instance.moduleHost.getChild(connectionId) + if (connection) { ps.push( - instance.startStopRecordingActions(true).catch((/** @type {any} */ e) => { - this.#logger.warn(`Failed to start recording for "${instanceId}": ${e}`) + connection.startStopRecordingActions(true).catch((/** @type {any} */ e) => { + this.#logger.warn(`Failed to start recording for "${connectionId}": ${e}`) }) ) } } // Find ones to stop recording - for (const instanceId of this.#currentlyRecordingInstanceIds.values()) { - if (!targetRecordingInstanceIds.has(instanceId)) { - const instance = this.#registry.instance.moduleHost.getChild(instanceId) - if (instance) { + for (const connectionId of this.#currentlyRecordingConnectionIds.values()) { + if (!targetRecordingConnectionIds.has(connectionId)) { + const connection = this.#registry.instance.moduleHost.getChild(connectionId) + if (connection) { ps.push( - instance.startStopRecordingActions(false).catch((/** @type {any} */ e) => { - this.#logger.warn(`Failed to stop recording for "${instanceId}": ${e}`) + connection.startStopRecordingActions(false).catch((/** @type {any} */ e) => { + this.#logger.warn(`Failed to stop recording for "${connectionId}": ${e}`) }) ) } } } - this.#currentlyRecordingInstanceIds = targetRecordingInstanceIds + this.#currentlyRecordingConnectionIds = targetRecordingConnectionIds // Wait for them all to be synced Promise.all(ps).catch((e) => { diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index d24f37156e..9a4e2d1abb 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -230,11 +230,11 @@ export default class ControlBase extends CoreBase { } /** - * Prune any items on controls which belong to an unknown instanceId - * @param {Set} _knownInstanceIds + * Prune any items on controls which belong to an unknown connectionId + * @param {Set} _knownConnectionIds * @access public */ - verifyInstanceIds(_knownInstanceIds) { + verifyConnectionIds(_knownConnectionIds) { // To be implemented by subclasses } diff --git a/lib/Controls/ControlTypes/Button/Base.js b/lib/Controls/ControlTypes/Button/Base.js index 6e75ada5f1..1c1a1a88a9 100644 --- a/lib/Controls/ControlTypes/Button/Base.js +++ b/lib/Controls/ControlTypes/Button/Base.js @@ -187,13 +187,13 @@ export default class ButtonControlBase extends ControlBase { } /** - * Remove any tracked state for an instance - * @param {string} instanceId + * Remove any tracked state for a connection + * @param {string} connectionId * @returns {void} * @access public */ - clearInstanceState(instanceId) { - this.feedbacks.clearInstanceState(instanceId) + clearConnectionState(connectionId) { + this.feedbacks.clearConnectionState(connectionId) } /** @@ -212,17 +212,17 @@ export default class ButtonControlBase extends ControlBase { } /** - * Remove any actions and feedbacks referencing a specified instanceId - * @param {string} instanceId + * Remove any actions and feedbacks referencing a specified connectionId + * @param {string} connectionId * @returns {void} * @access public */ - forgetInstance(instanceId) { - const changedFeedbacks = this.feedbacks.forgetInstance(instanceId) + forgetConnection(connectionId) { + const changedFeedbacks = this.feedbacks.forgetConnection(connectionId) let changedSteps = false for (const step of Object.values(this.steps)) { - const changed = step.forgetInstance(instanceId) + const changed = step.forgetConnection(connectionId) changedSteps = changedSteps || changed } @@ -486,15 +486,15 @@ export default class ButtonControlBase extends ControlBase { /** * Prune all actions/feedbacks referencing unknown instances * Doesn't do any cleanup, as it is assumed that the instance has not been running - * @param {Set} knownInstanceIds + * @param {Set} knownConnectionIds * @access public */ - verifyInstanceIds(knownInstanceIds) { - const changedFeedbacks = this.feedbacks.verifyInstanceIds(knownInstanceIds) + verifyConnectionIds(knownConnectionIds) { + const changedFeedbacks = this.feedbacks.verifyConnectionIds(knownConnectionIds) let changedSteps = false for (const step of Object.values(this.steps)) { - const changed = step.verifyInstanceIds(knownInstanceIds) + const changed = step.verifyConnectionIds(knownConnectionIds) changedSteps = changedSteps || changed } diff --git a/lib/Controls/ControlTypes/Triggers/Trigger.js b/lib/Controls/ControlTypes/Triggers/Trigger.js index 39ff8306f5..51d1767afe 100644 --- a/lib/Controls/ControlTypes/Triggers/Trigger.js +++ b/lib/Controls/ControlTypes/Triggers/Trigger.js @@ -395,12 +395,12 @@ export default class ControlTrigger extends ControlBase { } /** - * Remove any tracked state for an instance - * @param {string} instanceId + * Remove any tracked state for a connection + * @param {string} connectionId * @access public */ - clearInstanceState(instanceId) { - this.feedbacks.clearInstanceState(instanceId) + clearConnectionState(connectionId) { + this.feedbacks.clearConnectionState(connectionId) } /** @@ -562,13 +562,13 @@ export default class ControlTrigger extends ControlBase { } /** - * Remove any actions and feedbacks referencing a specified instanceId - * @param {string} instanceId + * Remove any actions and feedbacks referencing a specified connectionId + * @param {string} connectionId * @access public */ - forgetInstance(instanceId) { - const changedFeedbacks = this.feedbacks.forgetInstance(instanceId) - const changedActions = this.actions.forgetInstance(instanceId) + forgetConnection(connectionId) { + const changedFeedbacks = this.feedbacks.forgetConnection(connectionId) + const changedActions = this.actions.forgetConnection(connectionId) if (changedFeedbacks || changedActions) { this.commitChange(changedFeedbacks) @@ -769,12 +769,12 @@ export default class ControlTrigger extends ControlBase { /** * Prune all actions/feedbacks referencing unknown instances * Doesn't do any cleanup, as it is assumed that the instance has not been running - * @param {Set} knownInstanceIds + * @param {Set} knownConnectionIds * @access public */ - verifyInstanceIds(knownInstanceIds) { - const changedActions = this.actions.verifyInstanceIds(knownInstanceIds) - const changedFeedbacks = this.feedbacks.verifyInstanceIds(knownInstanceIds) + verifyConnectionIds(knownConnectionIds) { + const changedActions = this.actions.verifyConnectionIds(knownConnectionIds) + const changedFeedbacks = this.feedbacks.verifyConnectionIds(knownConnectionIds) if (changedFeedbacks || changedActions) { this.commitChange(changedFeedbacks) diff --git a/lib/Controls/Controller.js b/lib/Controls/Controller.js index 5d3df4bc2f..c835f6b7f0 100644 --- a/lib/Controls/Controller.js +++ b/lib/Controls/Controller.js @@ -113,14 +113,14 @@ class ControlsController extends CoreBase { ) /** - * Remove any tracked state for an instance - * @param {string} instanceId + * Remove any tracked state for a connection + * @param {string} connectionId * @access public */ - clearInstanceState(instanceId) { + clearConnectionState(connectionId) { for (const control of this.#controls.values()) { if (control.supportsActions || control.supportsFeedbacks) { - control.clearInstanceState(instanceId) + control.clearConnectionState(connectionId) } } } @@ -371,17 +371,17 @@ class ControlsController extends CoreBase { 'controls:feedback:add', /** * @param {string} controlId - * @param {string} instanceId + * @param {string} connectionId * @param {string} feedbackId * @returns {boolean} */ - (controlId, instanceId, feedbackId) => { + (controlId, connectionId, feedbackId) => { const control = this.getControl(controlId) if (!control) return false if (control.supportsFeedbacks) { const feedbackItem = this.instance.definitions.createFeedbackItem( - instanceId, + connectionId, feedbackId, control.feedbacks.isBooleanOnly ) @@ -616,16 +616,16 @@ class ControlsController extends CoreBase { * @param {string} controlId * @param {string} stepId * @param {string} setId - * @param {string} instanceId + * @param {string} connectionId * @param {string} actionId * @returns {boolean} */ - (controlId, stepId, setId, instanceId, actionId) => { + (controlId, stepId, setId, connectionId, actionId) => { const control = this.getControl(controlId) if (!control) return false if (control.supportsActions) { - const actionItem = this.instance.definitions.createActionItem(instanceId, actionId) + const actionItem = this.instance.definitions.createActionItem(connectionId, actionId) if (actionItem) { return control.actionAdd(stepId, setId, actionItem) } else { @@ -1254,15 +1254,15 @@ class ControlsController extends CoreBase { } /** - * Update all controls to forget an instance - * @param {string} instanceId + * Update all controls to forget a connection + * @param {string} connectionId * @returns {void} * @access public */ - forgetInstance(instanceId) { + forgetConnection(connectionId) { for (const control of this.#controls.values()) { if (control.supportsActions || control.supportsFeedbacks) { - control.forgetInstance(instanceId) + control.forgetConnection(connectionId) } } } @@ -1506,11 +1506,11 @@ class ControlsController extends CoreBase { /** * Update values for some feedbacks - * @param {string} instanceId + * @param {string} connectionId * @param {NewFeedbackValue[]} result - object containing new values for the feedbacks that have changed * @access public */ - updateFeedbackValues(instanceId, result) { + updateFeedbackValues(connectionId, result) { if (result.length === 0) return /** @type {Record>} */ @@ -1526,7 +1526,7 @@ class ControlsController extends CoreBase { for (const [controlId, newValues] of Object.entries(values)) { const control = this.getControl(controlId) if (control && control.supportsFeedbacks) { - control.feedbacks.updateFeedbackValues(instanceId, newValues) + control.feedbacks.updateFeedbackValues(connectionId, newValues) } } } @@ -1558,15 +1558,15 @@ class ControlsController extends CoreBase { } /** - * Prune any items on controls which belong to an unknown instanceId + * Prune any items on controls which belong to an unknown connectionId * @access public */ - verifyInstanceIds() { - const knownInstanceIds = new Set(this.instance.getAllInstanceIds()) - knownInstanceIds.add('internal') + verifyConnectionIds() { + const knownConnectionIds = new Set(this.instance.getAllInstanceIds()) + knownConnectionIds.add('internal') for (const control of this.#controls.values()) { - control.verifyInstanceIds(knownInstanceIds) + control.verifyConnectionIds(knownConnectionIds) } } } diff --git a/lib/Controls/Fragments/FragmentActions.js b/lib/Controls/Fragments/FragmentActions.js index c89bccdbc6..30cb7bd6ab 100644 --- a/lib/Controls/Fragments/FragmentActions.js +++ b/lib/Controls/Fragments/FragmentActions.js @@ -422,12 +422,12 @@ export default class FragmentActions extends CoreBase { } /** - * Remove any actions referencing a specified instanceId - * @param {string} instanceId + * Remove any actions referencing a specified connectionId + * @param {string} connectionId * @returns {boolean} * @access public */ - forgetInstance(instanceId) { + forgetConnection(connectionId) { let changed = false // Cleanup any actions @@ -436,7 +436,7 @@ export default class FragmentActions extends CoreBase { const newActions = [] for (const action of action_set) { - if (action.instance === instanceId) { + if (action.instance === connectionId) { this.cleanupAction(action) changed = true } else { @@ -502,13 +502,13 @@ export default class FragmentActions extends CoreBase { } /** - * Prune all actions/feedbacks referencing unknown instances - * Doesn't do any cleanup, as it is assumed that the instance has not been running - * @param {Set} knownInstanceIds + * Prune all actions/feedbacks referencing unknown connections + * Doesn't do any cleanup, as it is assumed that the connection has not been running + * @param {Set} knownConnectionIds * @returns {boolean} Whether any changes were made * @access public */ - verifyInstanceIds(knownInstanceIds) { + verifyConnectionIds(knownConnectionIds) { let changed = false // Clean out actions @@ -517,7 +517,7 @@ export default class FragmentActions extends CoreBase { const lengthBefore = existing_set.length const filtered_set = (this.action_sets[setId] = existing_set.filter( - (action) => !!action && knownInstanceIds.has(action.instance) + (action) => !!action && knownConnectionIds.has(action.instance) )) changed = changed || filtered_set.length !== lengthBefore } diff --git a/lib/Controls/Fragments/FragmentFeedbacks.js b/lib/Controls/Fragments/FragmentFeedbacks.js index d48cbb0d58..375090eb6d 100644 --- a/lib/Controls/Fragments/FragmentFeedbacks.js +++ b/lib/Controls/Fragments/FragmentFeedbacks.js @@ -151,9 +151,9 @@ export default class FragmentFeedbacks extends CoreBase { */ #cleanupFeedback(feedback) { // Inform relevant module - const instance = this.instance.moduleHost.getChild(feedback.instance_id, true) - if (instance) { - instance.feedbackDelete(feedback).catch((/** @type {any} */ e) => { + const connection = this.instance.moduleHost.getChild(feedback.instance_id, true) + if (connection) { + connection.feedbackDelete(feedback).catch((/** @type {any} */ e) => { this.logger.silly(`feedback_delete to connection failed: ${e.message}`) }) } @@ -163,14 +163,14 @@ export default class FragmentFeedbacks extends CoreBase { } /** - * Remove any tracked state for an instance - * @param {string} instanceId + * Remove any tracked state for a connection + * @param {string} connectionId * @access public */ - clearInstanceState(instanceId) { + clearConnectionState(connectionId) { let changed = false for (const feedback of this.feedbacks) { - if (feedback.instance_id === instanceId) { + if (feedback.instance_id === connectionId) { delete this.#cachedFeedbackValues[feedback.id] changed = true @@ -523,9 +523,9 @@ export default class FragmentFeedbacks extends CoreBase { if (feedback.instance_id === 'internal') { this.internalModule.feedbackUpdate(feedback, this.controlId) } else { - const instance = this.instance.moduleHost.getChild(feedback.instance_id, true) - if (instance) { - instance.feedbackUpdate(feedback, this.controlId).catch((/** @type {any} */ e) => { + const connection = this.instance.moduleHost.getChild(feedback.instance_id, true) + if (connection) { + connection.feedbackUpdate(feedback, this.controlId).catch((/** @type {any} */ e) => { this.logger.silly(`feedback_update to connection failed: ${e.message} ${e.stack}`) }) } @@ -534,18 +534,18 @@ export default class FragmentFeedbacks extends CoreBase { } /** - * Remove any actions referencing a specified instanceId - * @param {string} instanceId + * Remove any actions referencing a specified connectionId + * @param {string} connectionId * @returns {boolean} * @access public */ - forgetInstance(instanceId) { + forgetConnection(connectionId) { let changed = false // Cleanup any feedbacks const newFeedbacks = [] for (const feedback of this.feedbacks) { - if (feedback.instance_id === instanceId) { + if (feedback.instance_id === connectionId) { this.#cleanupFeedback(feedback) changed = true } else { @@ -676,15 +676,15 @@ export default class FragmentFeedbacks extends CoreBase { /** * Update the feedbacks on the button with new values - * @param {string} instanceId The instance the feedbacks are for + * @param {string} connectionId The instance the feedbacks are for * @param {Record} newValues The new fedeback values * @returns {void} */ - updateFeedbackValues(instanceId, newValues) { + updateFeedbackValues(connectionId, newValues) { let changed = false for (const feedback of this.feedbacks) { - if (feedback.instance_id === instanceId) { + if (feedback.instance_id === connectionId) { if (feedback.id in newValues) { // Feedback is present in new values (might be set to undefined) const value = newValues[feedback.id] @@ -704,17 +704,17 @@ export default class FragmentFeedbacks extends CoreBase { } /** - * Prune all actions/feedbacks referencing unknown instances - * Doesn't do any cleanup, as it is assumed that the instance has not been running - * @param {Set} knownInstanceIds + * Prune all actions/feedbacks referencing unknown conncetions + * Doesn't do any cleanup, as it is assumed that the connection has not been running + * @param {Set} knownConnectionIds * @access public */ - verifyInstanceIds(knownInstanceIds) { + verifyConnectionIds(knownConnectionIds) { let changed = false // Clean out feedbacks const feedbackLength = this.feedbacks.length - this.feedbacks = this.feedbacks.filter((feedback) => !!feedback && knownInstanceIds.has(feedback.instance_id)) + this.feedbacks = this.feedbacks.filter((feedback) => !!feedback && knownConnectionIds.has(feedback.instance_id)) changed = changed || this.feedbacks.length !== feedbackLength return changed diff --git a/lib/Controls/IControlFragments.js b/lib/Controls/IControlFragments.js index 947898c642..fd197faab5 100644 --- a/lib/Controls/IControlFragments.js +++ b/lib/Controls/IControlFragments.js @@ -191,22 +191,22 @@ export class ControlWithFeedbacks extends ControlBase { // } /** - * Remove any tracked state for an instance - * @param {string} _instanceId + * Remove any tracked state for an connection + * @param {string} _connectionId * @returns {void} * @access public */ - clearInstanceState(_instanceId) { + clearConnectionState(_connectionId) { throw new Error('Not implemented') } /** - * Update all controls to forget an instance - * @param {string} _instanceId + * Update all controls to forget an connection + * @param {string} _connectionId * @returns {void} * @access public */ - forgetInstance(_instanceId) { + forgetConnection(_connectionId) { throw new Error('Not implemented') } } @@ -377,22 +377,22 @@ export class ControlWithActions extends ControlBase { } /** - * Remove any tracked state for an instance - * @param {string} _instanceId + * Remove any tracked state for a connection + * @param {string} _connectionId * @returns {void} * @access public */ - clearInstanceState(_instanceId) { + clearConnectionState(_connectionId) { throw new Error('Not implemented') } /** - * Update all controls to forget an instance - * @param {string} _instanceId + * Update all controls to forget a connection + * @param {string} _connectionId * @returns {void} * @access public */ - forgetInstance(_instanceId) { + forgetConnection(_connectionId) { throw new Error('Not implemented') } diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index 61ad16e10d..e05cc530de 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -316,9 +316,9 @@ class Instance extends CoreBase { .then(() => { this.status.updateInstanceStatus(id, null, 'Disabled') - this.definitions.forgetInstance(id) - this.variable.forgetInstance(id, label) - this.controls.clearInstanceState(id) + this.definitions.forgetConnection(id) + this.variable.forgetConnection(id, label) + this.controls.clearConnectionState(id) }) } else { this.activate_module(id) @@ -349,15 +349,15 @@ class Instance extends CoreBase { this.logger.debug(`Error while deleting instance "${label ?? id}": `, e) } - this.status.forgetInstanceStatus(id) + this.status.forgetConnectionStatus(id) delete this.store.db[id] this.commitChanges() // forward cleanup elsewhere - this.definitions.forgetInstance(id) - this.variable.forgetInstance(id, label) - this.controls.forgetInstance(id) + this.definitions.forgetConnection(id) + this.variable.forgetConnection(id, label) + this.controls.forgetConnection(id) } /** diff --git a/lib/Instance/Definitions.js b/lib/Instance/Definitions.js index 7dae8d28c8..dadb44d3fd 100644 --- a/lib/Instance/Definitions.js +++ b/lib/Instance/Definitions.js @@ -107,8 +107,8 @@ class InstanceDefinitions extends CoreBase { client.onPromise( 'presets:preview_render', - async (/** @type {string } */ instanceId, /** @type {string } */ presetId) => { - const definition = this.#presetDefinitions[instanceId]?.[presetId] + async (/** @type {string } */ connectionId, /** @type {string } */ presetId) => { + const definition = this.#presetDefinitions[connectionId]?.[presetId] if (definition) { const style = { ...(definition.previewStyle ? definition.previewStyle : definition.style), @@ -132,17 +132,17 @@ class InstanceDefinitions extends CoreBase { /** * Create a action item without saving - * @param {string} instanceId - the id of the instance + * @param {string} connectionId - the id of the instance * @param {string} actionId - the id of the action * @access public */ - createActionItem(instanceId, actionId) { - const definition = this.getActionDefinition(instanceId, actionId) + createActionItem(connectionId, actionId) { + const definition = this.getActionDefinition(connectionId, actionId) if (definition) { const action = { id: nanoid(), action: actionId, - instance: instanceId, + instance: connectionId, options: {}, delay: 0, } @@ -232,7 +232,7 @@ class InstanceDefinitions extends CoreBase { * @param {string} connectionId * @access public */ - forgetInstance(connectionId) { + forgetConnection(connectionId) { delete this.#presetDefinitions[connectionId] if (this.io.countRoomMembers(PresetsRoom) > 0) { this.io.emitToRoom(PresetsRoom, 'presets:update', connectionId, undefined) @@ -251,13 +251,13 @@ class InstanceDefinitions extends CoreBase { /** * Get an action definition - * @param {string} instanceId + * @param {string} connectionId * @param {string} actionId * @access public */ - getActionDefinition(instanceId, actionId) { - if (this.#actionDefinitions[instanceId]) { - return this.#actionDefinitions[instanceId][actionId] + getActionDefinition(connectionId, actionId) { + if (this.#actionDefinitions[connectionId]) { + return this.#actionDefinitions[connectionId][actionId] } else { return undefined } @@ -265,13 +265,13 @@ class InstanceDefinitions extends CoreBase { /** * Get a feedback definition - * @param {string} instanceId + * @param {string} connectionId * @param {string} feedbackId * @access public */ - getFeedbackDefinition(instanceId, feedbackId) { - if (this.#feedbackDefinitions[instanceId]) { - return this.#feedbackDefinitions[instanceId][feedbackId] + getFeedbackDefinition(connectionId, feedbackId) { + if (this.#feedbackDefinitions[connectionId]) { + return this.#feedbackDefinitions[connectionId][feedbackId] } else { return undefined } @@ -370,7 +370,7 @@ class InstanceDefinitions extends CoreBase { /** * Set the feedback definitions for a connection - * @param {string} connectionId - the instance ID + * @param {string} connectionId - the connection ID * @param {Record} feedbackDefinitions - the feedback definitions * @access public */ @@ -519,8 +519,8 @@ class InstanceDefinitions extends CoreBase { } /* - * Clean up variable references: $(instance:variable) - * since the name of the instance is dynamic. We don't want to + * Clean up variable references: $(label:variable) + * since the name of the connection is dynamic. We don't want to * demand that your presets MUST be dynamically generated. */ for (const preset of Object.values(presets)) { diff --git a/lib/Instance/Host.js b/lib/Instance/Host.js index c95cc1e83d..eb3564d502 100644 --- a/lib/Instance/Host.js +++ b/lib/Instance/Host.js @@ -184,7 +184,7 @@ class ModuleHost { this.registry.instance.commitChanges() // Inform action recorder - this.registry.controls.actionRecorder.instanceAvailabilityChange(connectionId, true) + this.registry.controls.actionRecorder.connectionAvailabilityChange(connectionId, true) }) .catch((/** @type {any} */ e) => { this.#logger.warn(`Instance "${config.label || child.connectionId}" failed to init: ${e} ${e?.stack}`) @@ -452,7 +452,7 @@ class ModuleHost { this.#logger.debug(`Connection "${config.label}" stopped`) this.registry.io.emitToRoom(debugLogRoom, debugLogRoom, 'system', '** Connection stopped **') - this.registry.controls.actionRecorder.instanceAvailabilityChange(connectionId, false) + this.registry.controls.actionRecorder.connectionAvailabilityChange(connectionId, false) }) monitor.on('crash', () => { child.isReady = false diff --git a/lib/Instance/Status.js b/lib/Instance/Status.js index 4ddb01d1b3..d2b674ee07 100644 --- a/lib/Instance/Status.js +++ b/lib/Instance/Status.js @@ -147,7 +147,7 @@ export default class Status extends EventEmitter { * Forget the status of an instance * @param {string} connectionId */ - forgetInstanceStatus(connectionId) { + forgetConnectionStatus(connectionId) { const newStatuses = { ...this.#instanceStatuses } delete newStatuses[connectionId] diff --git a/lib/Instance/Variable.js b/lib/Instance/Variable.js index 6b134dd10a..c00e088afe 100644 --- a/lib/Instance/Variable.js +++ b/lib/Instance/Variable.js @@ -67,9 +67,9 @@ export function parseVariablesInString(string, rawVariableValues, cachedVariable } const fullId = matches[0] - const instanceId = matches[1] + const connectionLabel = matches[1] const variableId = matches[2] - referencedVariableIds.push(`${instanceId}:${variableId}`) + referencedVariableIds.push(`${connectionLabel}:${variableId}`) let cachedValue = cachedVariableValues[fullId] if (cachedValue === undefined) { @@ -77,7 +77,7 @@ export function parseVariablesInString(string, rawVariableValues, cachedVariable cachedVariableValues[fullId] = '$RE' // Fetch the raw value, and parse variables inside of it - const rawValue = rawVariableValues[instanceId]?.[variableId] + const rawValue = rawVariableValues[connectionLabel]?.[variableId] if (rawValue !== undefined) { const result = parseVariablesInString(rawValue, rawVariableValues, cachedVariableValues) cachedValue = result.text @@ -221,7 +221,7 @@ class InstanceVariable extends CoreBase { * @param {string} label * @returns {void} */ - forgetInstance(_id, label) { + forgetConnection(_id, label) { if (label !== undefined) { const valuesForLabel = this.#variableValues[label] if (valuesForLabel !== undefined) { diff --git a/lib/Internal/ActionRecorder.js b/lib/Internal/ActionRecorder.js index df3d2a3fd3..dced367ce0 100644 --- a/lib/Internal/ActionRecorder.js +++ b/lib/Internal/ActionRecorder.js @@ -220,7 +220,7 @@ export default class ActionRecorder { break } - this.#actionRecorder.setSelectedInstanceIds(Array.from(result)) + this.#actionRecorder.setSelectedConnectionIds(Array.from(result)) } return true diff --git a/lib/Registry.js b/lib/Registry.js index 82f9d3118f..322401cc47 100644 --- a/lib/Registry.js +++ b/lib/Registry.js @@ -248,7 +248,7 @@ class Registry extends EventEmitter { // old 'modules_loaded' events this.data.metrics.startCycle() - this.controls.verifyInstanceIds() + this.controls.verifyConnectionIds() this.instance.variable.custom.init() this.internalModule.init() this.graphics.regenerateAll(false) diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.jsx index 29502f0269..576953f7cb 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.jsx @@ -518,7 +518,7 @@ function TriggerPicker({ selectControl }) { function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }) { const socket = useContext(SocketContext) - const instances = useContext(ConnectionsContext) + const connections = useContext(ConnectionsContext) const doClearActions = useCallback(() => { socketEmitPromise(socket, 'action-recorder:session:discard-actions', [sessionId]).catch((e) => { @@ -559,19 +559,19 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } doFinish() }, [changeRecording, doFinish]) - const changeInstanceIds = useCallback( + const changeConnectionIds = useCallback( (ids) => { - socketEmitPromise(socket, 'action-recorder:session:set-instances', [sessionId, ids]).catch((e) => { + socketEmitPromise(socket, 'action-recorder:session:set-connections', [sessionId, ids]).catch((e) => { console.error(e) }) }, [socket, sessionId] ) - const instancesWhichCanRecord = useMemo(() => { + const connectionsWhichCanRecord = useMemo(() => { const result = [] - for (const [id, info] of Object.entries(instances)) { + for (const [id, info] of Object.entries(connections)) { if (info.hasRecordActionsHandler) { result.push({ id, @@ -581,7 +581,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } } return result - }, [instances]) + }, [connections]) if (!sessionInfo) return <> @@ -594,9 +594,9 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } Connections diff --git a/webui/src/Buttons/ButtonInfiniteGrid.jsx b/webui/src/Buttons/ButtonInfiniteGrid.jsx index 5e009a4744..fac17dd036 100644 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ b/webui/src/Buttons/ButtonInfiniteGrid.jsx @@ -211,7 +211,7 @@ export const PrimaryButtonGridIcon = memo(function PrimaryButtonGridIcon({ ...pr drop: (dropData) => { console.log('preset drop', dropData) const location = { pageNumber: props.pageNumber, column: props.column, row: props.row } - socketEmitPromise(socket, 'presets:import_to_bank', [dropData.instanceId, dropData.presetId, location]).catch( + socketEmitPromise(socket, 'presets:import_to_bank', [dropData.connectionId, dropData.presetId, location]).catch( (e) => { console.error('Preset import failed') } diff --git a/webui/src/Buttons/Presets.jsx b/webui/src/Buttons/Presets.jsx index 7fe94dab39..54c792f4d1 100644 --- a/webui/src/Buttons/Presets.jsx +++ b/webui/src/Buttons/Presets.jsx @@ -17,7 +17,7 @@ export const InstancePresets = function InstancePresets({ resetToken }) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) - const [instanceAndCategory, setInstanceAndCategory] = useState([null, null]) + const [connectionAndCategory, setConnectionAndCategory] = useState([null, null]) const [presetsMap, setPresetsMap] = useState(null) const [presetsError, setPresetError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) @@ -26,7 +26,7 @@ export const InstancePresets = function InstancePresets({ resetToken }) { // Reset selection on resetToken change useEffect(() => { - setInstanceAndCategory([null, null]) + setConnectionAndCategory([null, null]) }, [resetToken]) useEffect(() => { @@ -66,55 +66,55 @@ export const InstancePresets = function InstancePresets({ resetToken }) { ) } - if (instanceAndCategory[0]) { - const instance = connectionsContext[instanceAndCategory[0]] - const module = instance ? modules[instance.instance_type] : undefined + if (connectionAndCategory[0]) { + const connectionInfo = connectionsContext[connectionAndCategory[0]] + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined - const presets = presetsMap[instanceAndCategory[0]] ?? [] + const presets = presetsMap[connectionAndCategory[0]] ?? [] - if (instanceAndCategory[1]) { + if (connectionAndCategory[1]) { return ( ) } else { return ( ) } } else { - return + return } } -function PresetsInstanceList({ presets, setInstanceAndCategory }) { +function PresetsConnectionList({ presets, setConnectionAndCategory }) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) const options = Object.entries(presets).map(([id, vals]) => { if (!vals || Object.values(vals).length === 0) return '' - const instance = connectionsContext[id] - const module = instance ? modules[instance.instance_type] : undefined + const connectionInfo = connectionsContext[id] + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined return (
setInstanceAndCategory([id, null])} + className="choose_connection mr-2 mb-2" + onClick={() => setConnectionAndCategory([id, null])} > - {module?.name ?? '?'} ({instance?.label ?? id}) + {moduleInfo?.name ?? '?'} ({connectionInfo?.label ?? id})
) @@ -137,13 +137,13 @@ function PresetsInstanceList({ presets, setInstanceAndCategory }) { ) } -function PresetsCategoryList({ presets, instance, module, selectedInstanceId, setInstanceAndCategory }) { +function PresetsCategoryList({ presets, connectionInfo, moduleInfo, selectedConnectionId, setConnectionAndCategory }) { const categories = new Set() for (const preset of Object.values(presets)) { categories.add(preset.category) } - const doBack = useCallback(() => setInstanceAndCategory([null, null]), [setInstanceAndCategory]) + const doBack = useCallback(() => setConnectionAndCategory([null, null]), [setConnectionAndCategory]) const buttons = Array.from(categories).map((category) => { return ( @@ -151,7 +151,7 @@ function PresetsCategoryList({ presets, instance, module, selectedInstanceId, se key={category} color="danger" block - onClick={() => setInstanceAndCategory([selectedInstanceId, category])} + onClick={() => setConnectionAndCategory([selectedConnectionId, category])} > {category} @@ -164,7 +164,7 @@ function PresetsCategoryList({ presets, instance, module, selectedInstanceId, se Back - {module?.name ?? '?'} ({instance?.label ?? selectedInstanceId}) + {moduleInfo?.name ?? '?'} ({connectionInfo?.label ?? selectedConnectionId}) {buttons.length === 0 ? ( @@ -176,10 +176,10 @@ function PresetsCategoryList({ presets, instance, module, selectedInstanceId, se ) } -function PresetsButtonList({ presets, selectedInstanceId, selectedCategory, setInstanceAndCategory }) { +function PresetsButtonList({ presets, selectedConnectionId, selectedCategory, setConnectionAndCategory }) { const doBack = useCallback( - () => setInstanceAndCategory([selectedInstanceId, null]), - [setInstanceAndCategory, selectedInstanceId] + () => setConnectionAndCategory([selectedConnectionId, null]), + [setConnectionAndCategory, selectedConnectionId] ) const options = Object.values(presets).filter((p) => p.category === selectedCategory) @@ -198,7 +198,7 @@ function PresetsButtonList({ presets, selectedInstanceId, selectedCategory, setI return ( { setPreviewError(false) - socketEmitPromise(socket, 'presets:preview_render', [instanceId, preset.id]) + socketEmitPromise(socket, 'presets:preview_render', [connectionId, preset.id]) .then((img) => { setPreviewImage(img) }) @@ -236,7 +236,7 @@ function PresetIconPreview({ preset, instanceId, ...childProps }) { console.error('Failed to preview bank') setPreviewError(true) }) - }, [preset.id, socket, instanceId, retryToken]) + }, [preset.id, socket, connectionId, retryToken]) const onClick = useCallback((_location, isDown) => isDown && setRetryToken(nanoid()), []) diff --git a/webui/src/Buttons/Variables.jsx b/webui/src/Buttons/Variables.jsx index 21fe1c3930..1c53e9f905 100644 --- a/webui/src/Buttons/Variables.jsx +++ b/webui/src/Buttons/Variables.jsx @@ -4,44 +4,44 @@ import { ConnectionsContext, VariableDefinitionsContext, ModulesContext } from ' import { VariablesTable } from '../Components/VariablesTable' import { CustomVariablesList } from './CustomVariablesList' -export const InstanceVariables = function InstanceVariables({ resetToken }) { +export const ConnectionVariables = function ConnectionVariables({ resetToken }) { const connectionsContext = useContext(ConnectionsContext) - const [instanceId, setInstance] = useState(null) + const [connectionId, setConnectionId] = useState(null) const [showCustom, setShowCustom] = useState(false) - const instancesLabelMap = useMemo(() => { + const connectionsLabelMap = useMemo(() => { const labelMap = new Map() - for (const [id, instance] of Object.entries(connectionsContext)) { - labelMap.set(instance.label, id) + for (const [connectionId, connectionInfo] of Object.entries(connectionsContext)) { + labelMap.set(connectionInfo.label, connectionId) } return labelMap }, [connectionsContext]) // Reset selection on resetToken change useEffect(() => { - setInstance(null) + setConnectionId(null) }, [resetToken]) if (showCustom) { return - } else if (instanceId) { - let connectionLabel = connectionsContext[instanceId]?.label - if (instanceId === 'internal') connectionLabel = 'internal' + } else if (connectionId) { + let connectionLabel = connectionsContext[connectionId]?.label + if (connectionId === 'internal') connectionLabel = 'internal' - return + return } else { return ( - ) } } -function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap }) { +function VariablesConnectionList({ setConnectionId, setShowCustom, connectionsLabelMap }) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) const variableDefinitionsContext = useContext(VariableDefinitionsContext) @@ -52,21 +52,21 @@ function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap } if (label === 'internal') { return (
- setInstance('internal')}> + setConnectionId('internal')}> Internal
) } - const id = instancesLabelMap.get(label) - const instance = id ? connectionsContext[id] : undefined - const module = instance ? modules[instance.instance_type] : undefined + const connectionId = connectionsLabelMap.get(label) + const connectionInfo = connectionId ? connectionsContext[connectionId] : undefined + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined return ( -
- setInstance(id)}> - {module?.name ?? module?.name ?? '?'} ({label ?? id}) +
+ setConnectionId(connectionId)}> + {moduleInfo?.name ?? moduleInfo?.name ?? '?'} ({label ?? connectionId})
) @@ -77,7 +77,7 @@ function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap }
Variables

Some connection types provide variables for you to use in button text.

- setShowCustom(true)}> + setShowCustom(true)}> Custom Variables
@@ -86,8 +86,8 @@ function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap } ) } -function VariablesList({ selectedConnectionLabel, setInstance }) { - const doBack = useCallback(() => setInstance(null), [setInstance]) +function VariablesList({ selectedConnectionLabel, setConnectionId }) { + const doBack = useCallback(() => setConnectionId(null), [setConnectionId]) return (
diff --git a/webui/src/Buttons/index.jsx b/webui/src/Buttons/index.jsx index b923db9066..da0df9d3d9 100644 --- a/webui/src/Buttons/index.jsx +++ b/webui/src/Buttons/index.jsx @@ -9,7 +9,7 @@ import { EditButton } from './EditButton' import { ActionRecorder } from './ActionRecorder' import { memo, useCallback, useContext, useRef, useState } from 'react' import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { InstanceVariables } from './Variables' +import { ConnectionVariables } from './Variables' import { useElementSize } from 'usehooks-ts' import { formatLocation } from '@companion/shared/ControlId' @@ -254,7 +254,7 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { - + diff --git a/webui/src/Controls/ActionSetEditor.jsx b/webui/src/Controls/ActionSetEditor.jsx index 4ac05fed9d..3aa773aa9b 100644 --- a/webui/src/Controls/ActionSetEditor.jsx +++ b/webui/src/Controls/ActionSetEditor.jsx @@ -117,10 +117,12 @@ export function ControlActionSetEditor({ const addAction = useCallback( (actionType) => { - const [instanceId, actionId] = actionType.split(':', 2) - socketEmitPromise(socket, 'controls:action:add', [controlId, stepId, setId, instanceId, actionId]).catch((e) => { - console.error('Failed to add bank action', e) - }) + const [connectionId, actionId] = actionType.split(':', 2) + socketEmitPromise(socket, 'controls:action:add', [controlId, stepId, setId, connectionId, actionId]).catch( + (e) => { + console.error('Failed to add bank action', e) + } + ) }, [socket, controlId, stepId, setId] ) @@ -511,7 +513,7 @@ function ActionTableRow({ key={i} isOnControl={!!location} isAction={true} - instanceId={action.instance} + connectionId={action.instance} option={opt} actionId={action.id} value={(action.options || {})[opt.id]} @@ -555,12 +557,12 @@ function AddActionDropdown({ onSelect, placeholder }) { const options = useMemo(() => { const options = [] - for (const [instanceId, instanceActions] of Object.entries(actionsContext)) { - for (const [actionId, action] of Object.entries(instanceActions || {})) { - const connectionLabel = connectionsContext[instanceId]?.label ?? instanceId + for (const [connectionId, connectionActions] of Object.entries(actionsContext)) { + for (const [actionId, action] of Object.entries(connectionActions || {})) { + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId options.push({ isRecent: false, - value: `${instanceId}:${actionId}`, + value: `${connectionId}:${actionId}`, label: `${connectionLabel}: ${action.label}`, }) } @@ -569,13 +571,13 @@ function AddActionDropdown({ onSelect, placeholder }) { const recents = [] for (const actionType of recentActionsContext.recentActions) { if (actionType) { - const [instanceId, actionId] = actionType.split(':', 2) - const actionInfo = actionsContext[instanceId]?.[actionId] + const [connectionId, actionId] = actionType.split(':', 2) + const actionInfo = actionsContext[connectionId]?.[actionId] if (actionInfo) { - const connectionLabel = connectionsContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId recents.push({ isRecent: true, - value: `${instanceId}:${actionId}`, + value: `${connectionId}:${actionId}`, label: `${connectionLabel}: ${actionInfo.label}`, }) } diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.jsx index 6aef8e71d7..d344c99d17 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.jsx @@ -109,8 +109,8 @@ export function ControlFeedbacksEditor({ const addFeedback = useCallback( (feedbackType) => { - const [instanceId, feedbackId] = feedbackType.split(':', 2) - socketEmitPromise(socket, 'controls:feedback:add', [controlId, instanceId, feedbackId]).catch((e) => { + const [connectionId, feedbackId] = feedbackType.split(':', 2) + socketEmitPromise(socket, 'controls:feedback:add', [controlId, connectionId, feedbackId]).catch((e) => { console.error('Failed to add bank feedback', e) }) }, @@ -467,7 +467,7 @@ function FeedbackEditor({ { const options = [] - for (const [instanceId, instanceFeedbacks] of Object.entries(feedbacksContext)) { + for (const [connectionId, instanceFeedbacks] of Object.entries(feedbacksContext)) { for (const [feedbackId, feedback] of Object.entries(instanceFeedbacks || {})) { if (!booleanOnly || feedback.type === 'boolean') { - const connectionLabel = connectionsContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId options.push({ isRecent: false, - value: `${instanceId}:${feedbackId}`, + value: `${connectionId}:${feedbackId}`, label: `${connectionLabel}: ${feedback.label}`, }) } @@ -645,13 +645,13 @@ function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { const recents = [] for (const feedbackType of recentFeedbacksContext.recentFeedbacks || []) { if (feedbackType) { - const [instanceId, feedbackId] = feedbackType.split(':', 2) - const feedbackInfo = feedbacksContext[instanceId]?.[feedbackId] + const [connectionId, feedbackId] = feedbackType.split(':', 2) + const feedbackInfo = feedbacksContext[connectionId]?.[feedbackId] if (feedbackInfo) { - const connectionLabel = connectionsContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId recents.push({ isRecent: true, - value: `${instanceId}:${feedbackId}`, + value: `${connectionId}:${feedbackId}`, label: `${connectionLabel}: ${feedbackInfo.label}`, }) } diff --git a/webui/src/Controls/OptionsInputField.jsx b/webui/src/Controls/OptionsInputField.jsx index ba41fc240c..cf6264b243 100644 --- a/webui/src/Controls/OptionsInputField.jsx +++ b/webui/src/Controls/OptionsInputField.jsx @@ -12,7 +12,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, faQuestionCircle } from '@fortawesome/free-solid-svg-icons' export function OptionsInputField({ - instanceId, + connectionId, isOnControl, isAction, actionId, @@ -39,7 +39,7 @@ export function OptionsInputField({ required={option.required} placeholder={option.placeholder} useVariables={option.useVariables} - useInternalLocationVariables={instanceId === 'internal' && option.useInternalLocationVariables} + useInternalLocationVariables={connectionId === 'internal' && option.useInternalLocationVariables} disabled={readonly} setValue={setValue2} /> @@ -131,7 +131,7 @@ export function OptionsInputField({ } default: // The 'internal instance' is allowed to use some special input fields, to minimise when it reacts to changes elsewhere in the system - if (instanceId === 'internal') { + if (connectionId === 'internal') { control = InternalInstanceField(option, isOnControl, readonly, value, setValue2) } // Use default below diff --git a/webui/src/ImportExport/Import/Page.jsx b/webui/src/ImportExport/Import/Page.jsx index 7e0b007fd2..0a51bc73e4 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.jsx @@ -205,7 +205,7 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) { ) } -function ButtonImportPreview({ instanceId, ...props }) { +function ButtonImportPreview({ ...props }) { const socket = useContext(SocketContext) const [previewImage, setPreviewImage] = useState(null) diff --git a/webui/src/Triggers/EventEditor.jsx b/webui/src/Triggers/EventEditor.jsx index 634d9e1f87..0c5b5e3c3b 100644 --- a/webui/src/Triggers/EventEditor.jsx +++ b/webui/src/Triggers/EventEditor.jsx @@ -334,7 +334,7 @@ function EventEditor({ Date: Sat, 11 Nov 2023 15:30:44 +0000 Subject: [PATCH 20/53] fix: page buttons not drawing --- lib/Cloud/Controller.js | 2 +- lib/Controls/ControlBase.js | 9 +++++++++ lib/Controls/ControlTypes/Button/Normal.js | 1 + lib/Controls/IControlFragments.js | 19 ------------------- lib/Graphics/Controller.js | 2 +- lib/Service/EmberPlus.js | 5 +---- 6 files changed, 13 insertions(+), 25 deletions(-) diff --git a/lib/Cloud/Controller.js b/lib/Cloud/Controller.js index f9619b60e6..88b8e71026 100644 --- a/lib/Cloud/Controller.js +++ b/lib/Cloud/Controller.js @@ -184,7 +184,7 @@ class CloudController extends CoreBase { const retval = [] for (const control of this.controls.getAllControls().values()) { - if (control.type !== 'button' || !control.supportsStyle) continue + if (control.type !== 'button' || typeof control.getDrawStyle !== 'function') continue const drawStyle = control.getDrawStyle() if (drawStyle.style !== 'button') continue diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index 9a4e2d1abb..c7300ac6af 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -149,6 +149,15 @@ export default class ControlBase extends CoreBase { return null } + /** + * Get the complete style object of a button + * @returns {import('../Data/Model/StyleModel.js').DrawStyleModel | null} the processed style of the button + * @access public + */ + getDrawStyle() { + return null + } + /** * Emit a change to the runtime properties of this control. * This is for any properties that the ui may want about this control which are not persisted in toJSON() diff --git a/lib/Controls/ControlTypes/Button/Normal.js b/lib/Controls/ControlTypes/Button/Normal.js index cd61f3bb67..cee6bcb9e7 100644 --- a/lib/Controls/ControlTypes/Button/Normal.js +++ b/lib/Controls/ControlTypes/Button/Normal.js @@ -549,6 +549,7 @@ export default class ControlButtonNormal extends ButtonControlBase { */ getDrawStyle() { const style = super.getDrawStyle() + if (!style) return style if (GetStepIds(this.steps).length > 1) { style.step_cycle = this.getActiveStepIndex() + 1 diff --git a/lib/Controls/IControlFragments.js b/lib/Controls/IControlFragments.js index fd197faab5..4b95a9882f 100644 --- a/lib/Controls/IControlFragments.js +++ b/lib/Controls/IControlFragments.js @@ -121,15 +121,6 @@ export class ControlWithStyle extends ControlBase { */ bank_status = 'good' - /** - * Get the complete style object of a button - * @returns {import('../Data/Model/StyleModel.js').DrawStyleModel} the processed style of the button - * @access public - */ - getDrawStyle() { - throw new Error('Not implemented') - } - /** * Update the style fields of this control * @param {object} _diff - config diff to apply @@ -180,16 +171,6 @@ export class ControlWithFeedbacks extends ControlBase { */ feedbacks - // /** - // * Update the style fields of this control - // * @param {object} _diff - config diff to apply - // * @returns {boolean} true if any changes were made - // * @access public - // */ - // styleSetFields(_diff) { - // throw new Error('Not implemented') - // } - /** * Remove any tracked state for an connection * @param {string} _connectionId diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index bd91462cf6..43cec9c01e 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -113,7 +113,7 @@ class GraphicsController extends CoreBase { const controlId = this.page.getControlIdAt(location) const control = controlId ? this.controls.getControl(controlId) : undefined - const buttonStyle = control?.supportsStyle ? control.getDrawStyle() : undefined + const buttonStyle = control?.getDrawStyle() ?? undefined let render if (location && locationIsInBounds && buttonStyle && buttonStyle.style) { diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index b7da4f233f..99818e619d 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -120,10 +120,7 @@ class ServiceEmberPlus extends ServiceBase { const control = this.controls.getControl(controlId) /** @type {any} */ - let drawStyle = {} - if (control && control.supportsStyle) { - drawStyle = control.getDrawStyle() || {} - } + const drawStyle = control?.getDrawStyle() || {} children[bank] = new EmberModel.NumberedTreeNodeImpl( bank, From 76cbd59e9b59bb0d7edc1149a223c46488457a6e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 15:58:52 +0000 Subject: [PATCH 21/53] fix: type error --- lib/Cloud/Controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Cloud/Controller.js b/lib/Cloud/Controller.js index 88b8e71026..65d1262a76 100644 --- a/lib/Cloud/Controller.js +++ b/lib/Cloud/Controller.js @@ -184,9 +184,9 @@ class CloudController extends CoreBase { const retval = [] for (const control of this.controls.getAllControls().values()) { - if (control.type !== 'button' || typeof control.getDrawStyle !== 'function') continue + if (control.type !== 'button') continue const drawStyle = control.getDrawStyle() - if (drawStyle.style !== 'button') continue + if (!drawStyle || drawStyle.style !== 'button') continue // Don't expose a cloud control if (drawStyle.cloud) continue From 6eeb2bd19ec90715c253b305748ca4603735b725 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 11 Nov 2023 20:14:34 +0000 Subject: [PATCH 22/53] chore: rename some bank to button --- lib/Cloud/Controller.js | 2 +- lib/Controls/ControlBase.js | 2 +- lib/Controls/ControlTypes/Button/Base.js | 32 +++--- lib/Controls/ControlTypes/PageDown.js | 6 +- lib/Controls/ControlTypes/PageNumber.js | 6 +- lib/Controls/ControlTypes/PageUp.js | 6 +- lib/Controls/Controller.js | 34 +++--- lib/Controls/Fragments/FragmentFeedbacks.js | 4 +- lib/Controls/IControlFragments.js | 4 +- lib/Data/Model/StyleModel.ts | 2 +- lib/Data/UserConfig.js | 2 +- lib/Graphics/Controller.js | 8 +- lib/Graphics/Preview.js | 18 ++-- lib/Graphics/Renderer.js | 113 ++++++++++---------- lib/Graphics/Thread.js | 2 +- lib/Instance/Controller.js | 4 +- lib/Instance/Definitions.js | 6 +- lib/Instance/Status.js | 2 +- lib/Instance/Variable.js | 2 +- lib/Instance/Wrapper.js | 2 +- lib/Internal/Controller.js | 2 +- lib/Internal/Controls.js | 2 +- lib/Internal/Instance.js | 4 +- lib/Service/EmberPlus.js | 10 +- webui/src/Buttons/ActionRecorder.jsx | 8 +- webui/src/Buttons/ButtonGridActions.jsx | 6 +- webui/src/Buttons/ButtonGridPanel.jsx | 6 +- webui/src/Buttons/ButtonInfiniteGrid.jsx | 16 +-- webui/src/Buttons/EditButton.jsx | 4 +- webui/src/Buttons/Presets.jsx | 2 +- webui/src/Controls/ActionSetEditor.jsx | 14 +-- webui/src/Controls/FeedbackEditor.jsx | 2 +- webui/src/Controls/OptionButtonPreview.jsx | 2 +- webui/src/Emulator/Emulator.jsx | 4 +- webui/src/ImportExport/Import/Page.jsx | 2 +- webui/src/TabletView/ButtonsFromPage.jsx | 8 +- webui/src/Triggers/EditPanel.jsx | 6 +- webui/src/Triggers/EventEditor.jsx | 2 +- 38 files changed, 182 insertions(+), 175 deletions(-) diff --git a/lib/Cloud/Controller.js b/lib/Cloud/Controller.js index 65d1262a76..114c11ff21 100644 --- a/lib/Cloud/Controller.js +++ b/lib/Cloud/Controller.js @@ -205,7 +205,7 @@ class CloudController extends CoreBase { ...control.toJSON(false).style, pushed: control.supportsPushed && control.pushed, actions_running: control.supportsActions && control.has_actions_running, - bank_status: control.supportsStyle && control.bank_status, + bank_status: control.supportsStyle && control.button_status, style: 'button', }, }) diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index c7300ac6af..3b71c2f25a 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -56,7 +56,7 @@ export default class ControlBase extends CoreBase { #lastSentRuntimeJson = null /** - * Check the status of a bank, and re-draw if needed + * Check the status of a control, and re-draw if needed * @access public * @type {((redraw?: boolean) => boolean)=} * @returns {boolean} whether the status changed diff --git a/lib/Controls/ControlTypes/Button/Base.js b/lib/Controls/ControlTypes/Button/Base.js index 1c1a1a88a9..6b6ae189bc 100644 --- a/lib/Controls/ControlTypes/Button/Base.js +++ b/lib/Controls/ControlTypes/Button/Base.js @@ -86,7 +86,7 @@ export default class ButtonControlBase extends ControlBase { * @type {'good' | 'warning' | 'error'} * @access protected */ - bank_status = 'good' + button_status = 'good' /** * The config of this button @@ -139,13 +139,13 @@ export default class ButtonControlBase extends ControlBase { } /** - * Check the status of a bank, and re-draw if needed + * Check the status of a control, and re-draw if needed * @param {boolean} redraw whether to perform a draw * @returns {boolean} whether the status changed * @access public */ checkButtonStatus = (redraw = true) => { - // Find all the instances referenced by the bank + // Find all the connections referenced by the button const connectionIds = new Set() for (const step of Object.values(this.steps)) { for (const actions of Object.values(step.action_sets)) { @@ -160,10 +160,10 @@ export default class ButtonControlBase extends ControlBase { /** @type {'good' | 'warning' | 'error'} */ let status = 'good' for (const connectionId of connectionIds) { - const instance_status = this.instance.getInstanceStatus(connectionId) - if (instance_status) { + const connectionStatus = this.instance.getConnectionStatus(connectionId) + if (connectionStatus) { // TODO - can this be made simpler - switch (instance_status.category) { + switch (connectionStatus.category) { case 'error': status = 'error' break @@ -177,8 +177,8 @@ export default class ButtonControlBase extends ControlBase { } // If the status has changed, emit the eent - if (status != this.bank_status) { - this.bank_status = status + if (status != this.button_status) { + this.button_status = status if (redraw) this.triggerRedraw() return true } else { @@ -302,7 +302,7 @@ export default class ButtonControlBase extends ControlBase { pushed: !!this.pushed, action_running: this.has_actions_running, - bank_status: this.bank_status, + button_status: this.button_status, style: 'button', } @@ -317,7 +317,7 @@ export default class ButtonControlBase extends ControlBase { if (this.last_draw_variables) { for (const variable of allChangedVariables.values()) { if (this.last_draw_variables.has(variable)) { - this.logger.silly('variable changed in bank ' + this.controlId) + this.logger.silly('variable changed in button ' + this.controlId) this.triggerRedraw() return @@ -377,9 +377,9 @@ export default class ButtonControlBase extends ControlBase { } /** - * Rename an instance for variables used in this control - * @param {string} labelFrom - the old instance short name - * @param {string} labelTo - the new instance short name + * Rename a connection for variables used in this control + * @param {string} labelFrom - the old connection short name + * @param {string} labelTo - the new connection short name * @access public */ renameVariables(labelFrom, labelTo) { @@ -437,7 +437,7 @@ export default class ButtonControlBase extends ControlBase { const location = this.page.getLocationOfControlId(this.controlId) if (location) { - this.services.emberplus.updateBankState(location, this.pushed, surfaceId) + this.services.emberplus.updateButtonState(location, this.pushed, surfaceId) } this.triggerRedraw() @@ -484,8 +484,8 @@ export default class ButtonControlBase extends ControlBase { } /** - * Prune all actions/feedbacks referencing unknown instances - * Doesn't do any cleanup, as it is assumed that the instance has not been running + * Prune all actions/feedbacks referencing unknown connections + * Doesn't do any cleanup, as it is assumed that the connection has not been running * @param {Set} knownConnectionIds * @access public */ diff --git a/lib/Controls/ControlTypes/PageDown.js b/lib/Controls/ControlTypes/PageDown.js index c21cbf1aa0..c0d8d9d14b 100644 --- a/lib/Controls/ControlTypes/PageDown.js +++ b/lib/Controls/ControlTypes/PageDown.js @@ -121,9 +121,9 @@ export default class ControlButtonPageDown extends ControlBase { } /** - * Collect the instance ids and labels referenced by this control - * @param {Set} _foundConnectionIds - instance ids being referenced - * @param {Set} _foundConnectionLabels - instance labels being referenced + * Collect the connection ids and labels referenced by this control + * @param {Set} _foundConnectionIds - connection ids being referenced + * @param {Set} _foundConnectionLabels - connection labels being referenced * @access public */ collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { diff --git a/lib/Controls/ControlTypes/PageNumber.js b/lib/Controls/ControlTypes/PageNumber.js index 62351dcc65..3cdba2d5e9 100644 --- a/lib/Controls/ControlTypes/PageNumber.js +++ b/lib/Controls/ControlTypes/PageNumber.js @@ -122,9 +122,9 @@ export default class ControlButtonPageNumber extends ControlBase { } /** - * Collect the instance ids and labels referenced by this control - * @param {Set} _foundConnectionIds - instance ids being referenced - * @param {Set} _foundConnectionLabels - instance labels being referenced + * Collect the connection ids and labels referenced by this control + * @param {Set} _foundConnectionIds - connection ids being referenced + * @param {Set} _foundConnectionLabels - connection labels being referenced * @access public */ collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { diff --git a/lib/Controls/ControlTypes/PageUp.js b/lib/Controls/ControlTypes/PageUp.js index 29c79535a0..4aedf86ca7 100644 --- a/lib/Controls/ControlTypes/PageUp.js +++ b/lib/Controls/ControlTypes/PageUp.js @@ -121,9 +121,9 @@ export default class ControlButtonPageUp extends ControlBase { } /** - * Collect the instance ids and labels referenced by this control - * @param {Set} _foundConnectionIds - instance ids being referenced - * @param {Set} _foundConnectionLabels - instance labels being referenced + * Collect the connection ids and labels referenced by this control + * @param {Set} _foundConnectionIds - connection ids being referenced + * @param {Set} _foundConnectionLabels - connection labels being referenced * @access public */ collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { diff --git a/lib/Controls/Controller.js b/lib/Controls/Controller.js index c835f6b7f0..3a2c6931a6 100644 --- a/lib/Controls/Controller.js +++ b/lib/Controls/Controller.js @@ -93,7 +93,7 @@ class ControlsController extends CoreBase { } /** - * Check the instance-status of every control + * Check the connection-status of every control * @access public */ checkAllStatus = debounceFn( @@ -227,7 +227,7 @@ class ControlsController extends CoreBase { } const newControlId = CreateBankControlId(nanoid()) - const newControl = this.#createClassForControl(newControlId, 'bank', controlJson, true) + const newControl = this.#createClassForControl(newControlId, 'button', controlJson, true) if (newControl) { this.#controls.set(newControlId, newControl) @@ -1221,7 +1221,7 @@ class ControlsController extends CoreBase { /** * Create a new control class instance * @param {string} controlId Id of the control - * @param {string} category 'bank' | 'trigger' | 'all' + * @param {'button' | 'trigger' | 'all'} category 'button' | 'trigger' | 'all' * @param {SomeControlModel | string} controlObj The existing configuration of the control, or string type if it is a new control. Note: the control must be given a clone of an object * @param {boolean} isImport Whether this is an import, and needs additional processing * @returns {import('./IControlFragments.js').SomeControl | null} @@ -1230,7 +1230,7 @@ class ControlsController extends CoreBase { #createClassForControl(controlId, category, controlObj, isImport) { const controlType = typeof controlObj === 'object' ? controlObj.type : controlObj const controlObj2 = typeof controlObj === 'object' ? controlObj : null - if (category === 'all' || category === 'bank') { + if (category === 'all' || category === 'button') { if (controlObj2?.type === 'button' || (controlType === 'button' && !controlObj2)) { return new ControlButtonNormal(this.registry, controlId, controlObj2, isImport) } else if (controlObj2?.type === 'pagenum' || (controlType === 'pagenum' && !controlObj2)) { @@ -1336,7 +1336,7 @@ class ControlsController extends CoreBase { } const newControlId = forceControlId || CreateBankControlId(nanoid()) - const newControl = this.#createClassForControl(newControlId, 'bank', definition, true) + const newControl = this.#createClassForControl(newControlId, 'button', definition, true) if (newControl) { this.#controls.set(newControlId, newControl) @@ -1382,18 +1382,18 @@ class ControlsController extends CoreBase { } /** - * Propagate variable changes to the banks - * @param {Set} all_changed_variables_set + * Propagate variable changes to the controls + * @param {Set} allChangedVariablesSet * @access public */ - onVariablesChanged(all_changed_variables_set) { + onVariablesChanged(allChangedVariablesSet) { // Inform triggers of the change - this.triggers.emit('variables_changed', all_changed_variables_set) + this.triggers.emit('variables_changed', allChangedVariablesSet) - if (all_changed_variables_set.size > 0) { + if (allChangedVariablesSet.size > 0) { for (const control of this.#controls.values()) { if (control.supportsStyle) { - control.onVariablesChanged(all_changed_variables_set) + control.onVariablesChanged(allChangedVariablesSet) } } } @@ -1440,9 +1440,9 @@ class ControlsController extends CoreBase { } /** - * Rename an instance for variables used in the controls - * @param {string} labelFrom - the old instance short name - * @param {string} labelTo - the new instance short name + * Rename a connection for variables used in the controls + * @param {string} labelFrom - the old connection short name + * @param {string} labelTo - the new connection short name * @returns {void} * @access public */ @@ -1471,7 +1471,7 @@ class ControlsController extends CoreBase { this.page.setControlIdAt(location, null) // Notify interested parties - this.services.emberplus.updateBankState(location, false, undefined) + this.services.emberplus.updateButtonState(location, false, undefined) // Force a redraw this.graphics.invalidateButton(location) @@ -1489,14 +1489,14 @@ class ControlsController extends CoreBase { if (!this.page.isPageValid(location.pageNumber)) return null const controlId = CreateBankControlId(nanoid()) - const newControl = this.#createClassForControl(controlId, 'bank', newType, false) + const newControl = this.#createClassForControl(controlId, 'button', newType, false) if (!newControl) return null this.#controls.set(controlId, newControl) this.page.setControlIdAt(location, controlId) // Notify interested parties - this.services.emberplus.updateBankState(location, false, undefined) + this.services.emberplus.updateButtonState(location, false, undefined) // Force a redraw this.graphics.invalidateButton(location) diff --git a/lib/Controls/Fragments/FragmentFeedbacks.js b/lib/Controls/Fragments/FragmentFeedbacks.js index 375090eb6d..025f3a267a 100644 --- a/lib/Controls/Fragments/FragmentFeedbacks.js +++ b/lib/Controls/Fragments/FragmentFeedbacks.js @@ -445,7 +445,7 @@ export default class FragmentFeedbacks extends CoreBase { // preserve existing value newStyle[key] = oldStyle[key] } else { - // copy bank value, as a default + // copy button value as a default // @ts-ignore newStyle[key] = defaultStyle[key] !== undefined ? defaultStyle[key] : this.baseStyle[key] @@ -690,7 +690,7 @@ export default class FragmentFeedbacks extends CoreBase { const value = newValues[feedback.id] if (!isEqual(value, this.#cachedFeedbackValues[feedback.id])) { // Found the feedback, exactly where it said it would be - // Mark the bank as changed, and store the new value + // Mark the button as changed, and store the new value this.#cachedFeedbackValues[feedback.id] = value changed = true } diff --git a/lib/Controls/IControlFragments.js b/lib/Controls/IControlFragments.js index 4b95a9882f..d22a26c7d0 100644 --- a/lib/Controls/IControlFragments.js +++ b/lib/Controls/IControlFragments.js @@ -119,7 +119,7 @@ export class ControlWithStyle extends ControlBase { * @type {'good' | 'warning' | 'error'} * @access protected */ - bank_status = 'good' + button_status = 'good' /** * Update the style fields of this control @@ -271,7 +271,7 @@ export class ControlWithActions extends ControlBase { } /** - * Learn the options for an action, by asking the instance for the current values + * Learn the options for an action, by asking the connection for the current values * @param {string} _stepId * @param {string} _setId the id of the action set * @param {string} _id the id of the action diff --git a/lib/Data/Model/StyleModel.ts b/lib/Data/Model/StyleModel.ts index c11f32d251..5d27a64c69 100644 --- a/lib/Data/Model/StyleModel.ts +++ b/lib/Data/Model/StyleModel.ts @@ -14,7 +14,7 @@ export interface DrawStyleButtonModel extends ButtonStyleProperties { pushed: boolean step_cycle: number | undefined cloud: boolean | undefined - bank_status: 'error' | 'warning' | 'good' | undefined + button_status: 'error' | 'warning' | 'good' | undefined action_running: boolean | undefined } diff --git a/lib/Data/UserConfig.js b/lib/Data/UserConfig.js index 850d12ca62..f24837fe53 100644 --- a/lib/Data/UserConfig.js +++ b/lib/Data/UserConfig.js @@ -25,7 +25,7 @@ import CoreBase from '../Core/Base.js' */ class DataUserConfig extends CoreBase { /** - * The defaults for the bank fields + * The defaults for the user config fields * @type {import('./Model/UserConfigModel.js').UserConfigModel} * @access public * @static diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index 43cec9c01e..54b90c89fc 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -151,7 +151,7 @@ class GraphicsController extends CoreBase { location, pagename, ]) - render = GraphicsRenderer.wrapDrawBankImage(buffer, draw_style, buttonStyle) + render = GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, buttonStyle) } } else { render = GraphicsRenderer.drawBlank(this.#drawOptions, location) @@ -229,7 +229,7 @@ class GraphicsController extends CoreBase { imageBuffers: [], pushed: false, cloud: false, - bank_status: undefined, + button_status: undefined, step_cycle: undefined, action_running: false, @@ -241,7 +241,7 @@ class GraphicsController extends CoreBase { } const { buffer, draw_style } = await this.#pool.exec('drawBankImage', [this.#drawOptions, drawStyle]) - return GraphicsRenderer.wrapDrawBankImage(buffer, draw_style, drawStyle) + return GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, drawStyle) } /** @@ -260,7 +260,7 @@ class GraphicsController extends CoreBase { } else if (key == 'remove_topbar') { this.#drawOptions.remove_topbar = !!value this.logger.silly('Topbar removed') - // Delay redrawing to give instances a chance to adjust + // Delay redrawing to give connections a chance to adjust setTimeout(() => { this.instance.moduleHost.resubscribeAllFeedbacks() this.regenerateAll(false) diff --git a/lib/Graphics/Preview.js b/lib/Graphics/Preview.js index 128405a17c..9ef2225854 100644 --- a/lib/Graphics/Preview.js +++ b/lib/Graphics/Preview.js @@ -25,7 +25,7 @@ function ensureLocationIsNumber(location) { } /** - * The class that manages bank preview generation/relay for interfaces + * The class that manages button preview generation/relay for interfaces * * @author Håkon Nessjøen * @author Keith Rocheck @@ -72,10 +72,10 @@ class GraphicsPreview { #variablesController /** - * Current bank reference previews + * Current button reference previews * @type {Map} */ - #bankReferencePreviews = new Map() + #buttonReferencePreviews = new Map() /** * @param {import('./Controller.js').default} graphicsController @@ -164,13 +164,13 @@ class GraphicsPreview { (id, location, options) => { const fullId = `${client.id}::${id}` - if (this.#bankReferencePreviews.get(fullId)) throw new Error('Session id is already in use') + if (this.#buttonReferencePreviews.get(fullId)) throw new Error('Session id is already in use') // Do a resolve of the reference for the starting image const result = ParseInternalControlReference(this.#logger, this.#variablesController, location, options, true) // Track the subscription, to allow it to be invalidated - this.#bankReferencePreviews.set(fullId, { + this.#buttonReferencePreviews.set(fullId, { id, location, options, @@ -193,13 +193,13 @@ class GraphicsPreview { (id) => { const fullId = `${client.id}::${id}` - this.#bankReferencePreviews.delete(fullId) + this.#buttonReferencePreviews.delete(fullId) } ) } /** - * Send a bank update to the UIs + * Send a button update to the UIs * @param {import('../Resources/Util.js').ControlLocation} location * @param {import('./ImageResult.js').ImageResult} render * @access public @@ -220,7 +220,7 @@ class GraphicsPreview { } // Lookup any sessions - for (const previewSession of this.#bankReferencePreviews.values()) { + for (const previewSession of this.#buttonReferencePreviews.values()) { if (!previewSession.resolvedLocation) continue if (previewSession.resolvedLocation.pageNumber != location.pageNumber) continue if (previewSession.resolvedLocation.row != location.row) continue @@ -237,7 +237,7 @@ class GraphicsPreview { */ onVariablesChanged(allChangedSet) { // Lookup any sessions - for (const previewSession of this.#bankReferencePreviews.values()) { + for (const previewSession of this.#buttonReferencePreviews.values()) { if (!previewSession.referencedVariableIds || !previewSession.referencedVariableIds.length) continue const matchingChangedVariable = previewSession.referencedVariableIds.some((variable) => diff --git a/lib/Graphics/Renderer.js b/lib/Graphics/Renderer.js index 189136cd8c..95f2c3aa15 100644 --- a/lib/Graphics/Renderer.js +++ b/lib/Graphics/Renderer.js @@ -42,7 +42,7 @@ const internalIcons = { export default class GraphicsRenderer { /** - * Draw the image for an empty bank + * Draw the image for an empty button * @param {import('./Controller.js').GraphicsOptions} options * @param {import('../Resources/Util.js').ControlLocation} location * @access public @@ -66,43 +66,48 @@ export default class GraphicsRenderer { } /** - * Draw the image for a bank + * Draw the image for a button * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} bankStyle The style to draw + * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public * @returns {Promise} Image render object */ - static async drawBankImage(options, bankStyle, location, pagename) { - const { buffer, draw_style } = await GraphicsRenderer.drawBankImageUnwrapped(options, bankStyle, location, pagename) - - return GraphicsRenderer.wrapDrawBankImage(buffer, draw_style, bankStyle) + static async drawButtonImage(options, drawStyle, location, pagename) { + const { buffer, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( + options, + drawStyle, + location, + pagename + ) + + return GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, drawStyle) } /** * * @param {Buffer} buffer * @param {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} bankStyle + * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle * @returns */ - static wrapDrawBankImage(buffer, draw_style, bankStyle) { - const draw_style2 = draw_style === 'button' ? (bankStyle.style === 'button' ? bankStyle : undefined) : draw_style + static wrapDrawButtonImage(buffer, draw_style, drawStyle) { + const draw_style2 = draw_style === 'button' ? (drawStyle.style === 'button' ? drawStyle : undefined) : draw_style return new ImageResult(buffer, draw_style2) } /** - * Draw the image for a bank + * Draw the image for a btuton * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} bankStyle The style to draw + * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public * @returns {Promise<{ buffer: Buffer, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object */ - static async drawBankImageUnwrapped(options, bankStyle, location, pagename) { + static async drawButtonImageUnwrapped(options, drawStyle, location, pagename) { // console.log('starting drawBankImage '+ performance.now()) // console.time('drawBankImage') const img = new Image(72, 72, 4) @@ -111,7 +116,7 @@ export default class GraphicsRenderer { let draw_style = undefined // special button types - if (bankStyle.style == 'pageup') { + if (drawStyle.style == 'pageup') { draw_style = 'pageup' img.fillColor(colorDarkGrey) @@ -131,7 +136,7 @@ export default class GraphicsRenderer { } img.drawTextLineAligned(36, 39, 'UP', colorButtonYellow, 10, 'center', 'top') - } else if (bankStyle.style == 'pagedown') { + } else if (drawStyle.style == 'pagedown') { draw_style = 'pagedown' img.fillColor(colorDarkGrey) @@ -151,13 +156,13 @@ export default class GraphicsRenderer { } img.drawTextLineAligned(36, 23, 'DOWN', colorButtonYellow, 10, 'center', 'top') - } else if (bankStyle.style == 'pagenum') { + } else if (drawStyle.style == 'pagenum') { draw_style = 'pagenum' img.fillColor(colorDarkGrey) if (location === undefined) { - // Preview (no page/bank) + // Preview (no location) img.drawTextLineAligned(36, 18, 'PAGE', colorButtonYellow, 10, 'center', 'top') img.drawTextLineAligned(36, 32, 'x', colorWhite, 18, 'center', 'top') } else if (!pagename || pagename.toLowerCase() == 'page') { @@ -166,10 +171,10 @@ export default class GraphicsRenderer { } else { img.drawAlignedText(0, 0, 72, 72, pagename, colorWhite, 18, 'center', 'center') } - } else if (bankStyle.style === 'button') { + } else if (drawStyle.style === 'button') { draw_style = 'button' - await GraphicsRenderer.#drawButtonMain(img, options, bankStyle, location) + await GraphicsRenderer.#drawButtonMain(img, options, drawStyle, location) } // console.timeEnd('drawBankImage') @@ -183,35 +188,35 @@ export default class GraphicsRenderer { * Draw the main button * @param {Image} img Image to draw to * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} bankStyle The style to draw + * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @access private */ - static async #drawButtonMain(img, options, bankStyle, location) { - let show_topbar = !!bankStyle.show_topbar - if (bankStyle.show_topbar === 'default' || bankStyle.show_topbar === undefined) { + static async #drawButtonMain(img, options, drawStyle, location) { + let show_topbar = !!drawStyle.show_topbar + if (drawStyle.show_topbar === 'default' || drawStyle.show_topbar === undefined) { show_topbar = !options.remove_topbar } // handle upgrade from pre alignment-support configuration - if (bankStyle.alignment === undefined) { - bankStyle.alignment = 'center:center' + if (drawStyle.alignment === undefined) { + drawStyle.alignment = 'center:center' } - if (bankStyle.pngalignment === undefined) { - bankStyle.pngalignment = 'center:center' + if (drawStyle.pngalignment === undefined) { + drawStyle.pngalignment = 'center:center' } // Draw background color first !show_topbar - ? img.box(0, 0, 72, 72, parseColor(bankStyle.bgcolor)) - : img.box(0, 14, 72, 72, parseColor(bankStyle.bgcolor)) + ? img.box(0, 0, 72, 72, parseColor(drawStyle.bgcolor)) + : img.box(0, 14, 72, 72, parseColor(drawStyle.bgcolor)) // Draw background PNG if exists - if (bankStyle.png64 !== undefined && bankStyle.png64 !== null) { + if (drawStyle.png64 !== undefined && drawStyle.png64 !== null) { try { - let png64 = bankStyle.png64.startsWith('data:image/png;base64,') ? bankStyle.png64.slice(22) : bankStyle.png64 + let png64 = drawStyle.png64.startsWith('data:image/png;base64,') ? drawStyle.png64.slice(22) : drawStyle.png64 let data = Buffer.from(png64, 'base64') - const [halign, valign] = ParseAlignment(bankStyle.pngalignment) + const [halign, valign] = ParseAlignment(drawStyle.pngalignment) !show_topbar ? await img.drawFromPNGdata(data, 0, 0, 72, 72, halign, valign, 'fit') @@ -223,14 +228,14 @@ export default class GraphicsRenderer { ? img.drawAlignedText(2, 2, 68, 68, 'PNG ERROR', 'red', 10, 'center', 'center') : img.drawAlignedText(2, 18, 68, 52, 'PNG ERROR', 'red', 10, 'center', 'center') - GraphicsRenderer.#drawTopbar(img, show_topbar, bankStyle, location) + GraphicsRenderer.#drawTopbar(img, show_topbar, drawStyle, location) return } } // Draw images from feedbacks try { - for (const image of bankStyle.imageBuffers || []) { + for (const image of drawStyle.imageBuffers || []) { if (image.buffer) { const yOffset = show_topbar ? 14 : 0 @@ -248,44 +253,44 @@ export default class GraphicsRenderer { ? img.drawAlignedText(2, 2, 68, 68, 'IMAGE\\nDRAW\\nERROR', 'red', 10, 'center', 'center') : img.drawAlignedText(2, 18, 68, 52, 'IMAGE\\nDRAW\\nERROR', 'red', 10, 'center', 'center') - GraphicsRenderer.#drawTopbar(img, show_topbar, bankStyle, location) + GraphicsRenderer.#drawTopbar(img, show_topbar, drawStyle, location) return } // Draw button text - const [halign, valign] = ParseAlignment(bankStyle.alignment) + const [halign, valign] = ParseAlignment(drawStyle.alignment) /** @type {'auto' | number} */ let fontSize = 'auto' - if (bankStyle.size == 'small') { + if (drawStyle.size == 'small') { fontSize = 7 // compatibility with v1 database - } else if (bankStyle.size == 'large') { + } else if (drawStyle.size == 'large') { fontSize = 14 // compatibility with v1 database } else { - fontSize = Number(bankStyle.size) || 'auto' + fontSize = Number(drawStyle.size) || 'auto' } if (!show_topbar) { - img.drawAlignedText(2, 1, 68, 70, bankStyle.text, parseColor(bankStyle.color), fontSize, halign, valign) + img.drawAlignedText(2, 1, 68, 70, drawStyle.text, parseColor(drawStyle.color), fontSize, halign, valign) } else { - img.drawAlignedText(2, 15, 68, 57, bankStyle.text, parseColor(bankStyle.color), fontSize, halign, valign) + img.drawAlignedText(2, 15, 68, 57, drawStyle.text, parseColor(drawStyle.color), fontSize, halign, valign) } // At last draw Topbar on top - GraphicsRenderer.#drawTopbar(img, show_topbar, bankStyle, location) + GraphicsRenderer.#drawTopbar(img, show_topbar, drawStyle, location) } /** - * Draw the topbar onto an image for a bank + * Draw the topbar onto an image for a button * @param {Image} img Image to draw to * @param {boolean} show_topbar - * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} bankStyle The style to draw + * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @access private */ - static #drawTopbar(img, show_topbar, bankStyle, location) { + static #drawTopbar(img, show_topbar, drawStyle, location) { if (!show_topbar) { - if (bankStyle.pushed) { + if (drawStyle.pushed) { img.drawBorder(3, colorButtonYellow) } } else { @@ -293,14 +298,14 @@ export default class GraphicsRenderer { img.box(0, 0, 72, 13.5, colorBlack) img.horizontalLine(13.5, colorButtonYellow) - if (typeof bankStyle.step_cycle === 'number' && location) { - step = `.${bankStyle.step_cycle}` + if (typeof drawStyle.step_cycle === 'number' && location) { + step = `.${drawStyle.step_cycle}` } if (location === undefined) { - // Preview (no page/bank) + // Preview (no location) img.drawTextLine(4, 2, `x.x${step}`, colorButtonYellow, 9) - } else if (bankStyle.pushed) { + } else if (drawStyle.pushed) { img.box(0, 0, 72, 14, colorButtonYellow) img.drawTextLine(4, 2, `${formatLocation(location)}${step}`, colorBlack, 9) } else { @@ -312,14 +317,14 @@ export default class GraphicsRenderer { let rightMax = 72 // first the cloud icon if present - if (bankStyle.cloud && show_topbar) { + if (drawStyle.cloud && show_topbar) { img.drawPixelBuffer(rightMax - 17, 3, 15, 8, internalIcons.cloud) rightMax -= 17 } // next error or warning icon if (location) { - switch (bankStyle.bank_status) { + switch (drawStyle.button_status) { case 'error': img.box(rightMax - 10, 3, rightMax - 2, 11, 'red') rightMax -= 10 @@ -339,10 +344,10 @@ export default class GraphicsRenderer { } // last running icon - if (bankStyle.action_running) { + if (drawStyle.action_running) { //img.drawTextLine(55, 3, '►', 'rgb(0, 255, 0)', 8) // not as nice let iconcolor = 'rgb(0, 255, 0)' - if (bankStyle.pushed) iconcolor = colorBlack + if (drawStyle.pushed) iconcolor = colorBlack img.drawFilledPath( [ [rightMax - 8, 3], diff --git a/lib/Graphics/Thread.js b/lib/Graphics/Thread.js index f174a4def9..342a439708 100644 --- a/lib/Graphics/Thread.js +++ b/lib/Graphics/Thread.js @@ -19,5 +19,5 @@ import GraphicsRenderer from './Renderer.js' import workerPool from 'workerpool' workerPool.worker({ - drawBankImage: GraphicsRenderer.drawBankImageUnwrapped, + drawBankImage: GraphicsRenderer.drawButtonImageUnwrapped, }) diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index e05cc530de..4f85fd7efe 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -460,8 +460,8 @@ class Instance extends CoreBase { * @param {String} connectionId * @returns {import('./Status.js').StatusEntry} */ - getInstanceStatus(connectionId) { - return this.status.getInstanceStatus(connectionId) + getConnectionStatus(connectionId) { + return this.status.getConnectionStatus(connectionId) } /** diff --git a/lib/Instance/Definitions.js b/lib/Instance/Definitions.js index dadb44d3fd..d4f2dbb534 100644 --- a/lib/Instance/Definitions.js +++ b/lib/Instance/Definitions.js @@ -103,7 +103,7 @@ class InstanceDefinitions extends CoreBase { return EventDefinitions }) - client.onPromise('presets:import_to_bank', this.importPresetToBank.bind(this)) + client.onPromise('presets:import-to-location', this.importPresetToLocation.bind(this)) client.onPromise( 'presets:preview_render', @@ -278,14 +278,14 @@ class InstanceDefinitions extends CoreBase { } /** - * Import a preset onto a bank + * Import a preset to a location * @param {string} connectionId * @param {string} presetId * @param {import('../Resources/Util.js').ControlLocation} location * @returns {boolean} * @access public */ - importPresetToBank(connectionId, presetId, location) { + importPresetToLocation(connectionId, presetId, location) { const definition = this.#presetDefinitions[connectionId]?.[presetId] if (!definition) return false diff --git a/lib/Instance/Status.js b/lib/Instance/Status.js index d2b674ee07..6f6c455924 100644 --- a/lib/Instance/Status.js +++ b/lib/Instance/Status.js @@ -139,7 +139,7 @@ export default class Status extends EventEmitter { * @param {String} connectionId * @returns {StatusEntry} */ - getInstanceStatus(connectionId) { + getConnectionStatus(connectionId) { return this.#instanceStatuses[connectionId] } diff --git a/lib/Instance/Variable.js b/lib/Instance/Variable.js index c00e088afe..7ce2582f3c 100644 --- a/lib/Instance/Variable.js +++ b/lib/Instance/Variable.js @@ -251,7 +251,7 @@ class InstanceVariable extends CoreBase { const valuesTo = this.#variableValues[labelTo] || {} this.#variableValues[labelTo] = valuesTo - // Trigger any renames inside of the banks + // Trigger any renames inside of the controls this.controls.renameVariables(labelFrom, labelTo) // Move variable values, and track the 'diff' diff --git a/lib/Instance/Wrapper.js b/lib/Instance/Wrapper.js index 889cb52950..44e4967379 100644 --- a/lib/Instance/Wrapper.js +++ b/lib/Instance/Wrapper.js @@ -187,7 +187,7 @@ class SocketEventsHandler { /** @type {Record} */ const allFeedbacks = {} - // Find all the feedbacks on banks + // Find all the feedbacks on controls const allControls = this.registry.controls.getAllControls() for (const [controlId, control] of allControls.entries()) { if (control.supportsFeedbacks && control.feedbacks.feedbacks.length > 0) { diff --git a/lib/Internal/Controller.js b/lib/Internal/Controller.js index f4446c4f3c..32ec3ebdc1 100644 --- a/lib/Internal/Controller.js +++ b/lib/Internal/Controller.js @@ -62,7 +62,7 @@ export default class InternalController extends CoreBase { } init() { - // Find all the feedbacks on banks + // Find all the feedbacks on controls const allControls = this.registry.controls.getAllControls() for (const [controlId, control] of allControls.entries()) { // Discover feedbacks to process diff --git a/lib/Internal/Controls.js b/lib/Internal/Controls.js index 3b4fba2eeb..4034728d88 100644 --- a/lib/Internal/Controls.js +++ b/lib/Internal/Controls.js @@ -578,7 +578,7 @@ export default class Controls { bank_current_step: { type: 'boolean', label: 'Button: Check step', - description: 'Change style based on the current step of a bank', + description: 'Change style based on the current step of a button', showButtonPreview: true, style: { color: rgb(0, 0, 0), diff --git a/lib/Internal/Instance.js b/lib/Internal/Instance.js index 2e7af9e895..1cd3c5e712 100644 --- a/lib/Internal/Instance.js +++ b/lib/Internal/Instance.js @@ -253,7 +253,7 @@ export default class Instance { if (action.action === 'instance_control') { let newState = action.options.enable == 'true' if (action.options.enable == 'toggle') { - const curState = this.#instanceController.getInstanceStatus(action.options.instance_id) + const curState = this.#instanceController.getConnectionStatus(action.options.instance_id) newState = !curState?.category } @@ -293,7 +293,7 @@ export default class Instance { } } - const cur_instance = this.#instanceController.getInstanceStatus(feedback.options.instance_id) + const cur_instance = this.#instanceController.getConnectionStatus(feedback.options.instance_id) if (cur_instance !== undefined) { switch (cur_instance.category) { case 'error': diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index 99818e619d..d8ec8d0c10 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -269,14 +269,14 @@ class ServiceEmberPlus extends ServiceBase { switch (node) { case NODE_STATE: { - this.logger.silly(`Change bank ${controlId} pressed to ${value}`) + this.logger.silly(`Change button ${controlId} pressed to ${value}`) this.controls.pressControl(controlId, !!value, `emberplus`) this.server?.update(parameter, { value }) return true } case NODE_TEXT: { - this.logger.silly(`Change bank ${controlId} text to ${value}`) + this.logger.silly(`Change button ${controlId} text to ${value}`) const control = this.controls.getControl(controlId) if (control && control.supportsStyle) { @@ -290,7 +290,7 @@ class ServiceEmberPlus extends ServiceBase { } case NODE_TEXT_COLOR: { const color = parseHexColor(value + '') - this.logger.silly(`Change bank ${controlId} text color to ${value} (${color})`) + this.logger.silly(`Change button ${controlId} text color to ${value} (${color})`) const control = this.controls.getControl(controlId) if (control && control.supportsStyle) { @@ -304,7 +304,7 @@ class ServiceEmberPlus extends ServiceBase { } case NODE_BG_COLOR: { const color = parseHexColor(value + '') - this.logger.silly(`Change bank ${controlId} background color to ${value} (${color})`) + this.logger.silly(`Change button ${controlId} background color to ${value} (${color})`) const control = this.controls.getControl(controlId) if (control && control.supportsStyle) { @@ -328,7 +328,7 @@ class ServiceEmberPlus extends ServiceBase { * @param {boolean} pushed - the state * @param {string | undefined} surfaceId - checks the surfaceId to ensure that Ember+ doesn't loop its own state change back */ - updateBankState(location, pushed, surfaceId) { + updateButtonState(location, pushed, surfaceId) { if (!this.server) return if (surfaceId === 'emberplus') return diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.jsx index 576953f7cb..246a67f938 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.jsx @@ -237,7 +237,7 @@ function ButtonPicker({ selectButton }) { const [selectedStep, setSelectedStep] = useState(null) const [selectedSet, setSelectedSet] = useState(null) - const bankClick = useCallback( + const buttonClick = useCallback( (location, pressed) => { if (pressed) setSelectedLocation(location) }, @@ -269,7 +269,7 @@ function ButtonPicker({ selectButton }) { setControlInfo(config?.config ?? false) }) .catch((e) => { - console.error('Failed to load bank config', e) + console.error('Failed to load control config', e) setControlInfo(null) }) @@ -289,7 +289,7 @@ function ButtonPicker({ selectButton }) { socket.off(`controls:config-${selectedControl}`, patchConfig) socketEmitPromise(socket, 'controls:unsubscribe', [selectedControl]).catch((e) => { - console.error('Failed to unsubscribe bank config', e) + console.error('Failed to unsubscribe control config', e) }) } } @@ -401,7 +401,7 @@ function ButtonPicker({ selectButton }) { {hasBeenInView && ( ({ - bankClick(location, isDown) { + buttonClick(location, isDown) { if (isDown) { switch (activeFunction) { case 'delete': - resetRef.current.show('Clear bank', `Clear style and actions for this button?`, 'Clear', () => { + resetRef.current.show('Clear button', `Clear style and actions for this button?`, 'Clear', () => { socketEmitPromise(socket, 'controls:reset', [location]).catch((e) => { console.error(`Reset failed: ${e}`) }) @@ -134,7 +134,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( } return true default: - // show bank edit page + // show button edit page return false } } else { diff --git a/webui/src/Buttons/ButtonGridPanel.jsx b/webui/src/Buttons/ButtonGridPanel.jsx index bcd73f4bca..6268a555b0 100644 --- a/webui/src/Buttons/ButtonGridPanel.jsx +++ b/webui/src/Buttons/ButtonGridPanel.jsx @@ -53,9 +53,9 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ const actionsRef = useRef() - const bankClick = useCallback( + const buttonClick = useCallback( (location, isDown) => { - if (!actionsRef.current?.bankClick(location, isDown)) { + if (!actionsRef.current?.buttonClick(location, isDown)) { buttonGridClick(location, isDown) } }, @@ -188,7 +188,7 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ ref={gridRef} isHot={isHot} pageNumber={pageNumber} - bankClick={bankClick} + buttonClick={buttonClick} selectedButton={selectedButton} gridSize={gridSize} doGrow={userConfig.gridSizeInlineGrow ? doGrow : undefined} diff --git a/webui/src/Buttons/ButtonInfiniteGrid.jsx b/webui/src/Buttons/ButtonInfiniteGrid.jsx index fac17dd036..e0ebd5942b 100644 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ b/webui/src/Buttons/ButtonInfiniteGrid.jsx @@ -20,7 +20,7 @@ import { useButtonRenderCache } from '../Hooks/useSharedRenderCache' import { CButton, CInput } from '@coreui/react' export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid( - { isHot, pageNumber, bankClick, selectedButton, gridSize, doGrow, buttonIconFactory }, + { isHot, pageNumber, buttonClick, selectedButton, gridSize, doGrow, buttonIconFactory }, ref ) { const { minColumn, maxColumn, minRow, maxRow } = gridSize @@ -96,7 +96,7 @@ export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid( row, column, pageNumber, - onClick: bankClick, + onClick: buttonClick, selected: selectedButton?.pageNumber === pageNumber && selectedButton?.column === column && @@ -211,11 +211,13 @@ export const PrimaryButtonGridIcon = memo(function PrimaryButtonGridIcon({ ...pr drop: (dropData) => { console.log('preset drop', dropData) const location = { pageNumber: props.pageNumber, column: props.column, row: props.row } - socketEmitPromise(socket, 'presets:import_to_bank', [dropData.connectionId, dropData.presetId, location]).catch( - (e) => { - console.error('Preset import failed') - } - ) + socketEmitPromise(socket, 'presets:import-to-location', [ + dropData.connectionId, + dropData.presetId, + location, + ]).catch((e) => { + console.error('Preset import failed') + }) }, collect: (monitor) => ({ isOver: !!monitor.isOver(), diff --git a/webui/src/Buttons/EditButton.jsx b/webui/src/Buttons/EditButton.jsx index 4f6ea0a709..a797af3897 100644 --- a/webui/src/Buttons/EditButton.jsx +++ b/webui/src/Buttons/EditButton.jsx @@ -94,9 +94,9 @@ export function EditButton({ location, onKeyUp, contentHeight }) { setConfigError(null) }) .catch((e) => { - console.error('Failed to load bank config', e) + console.error('Failed to load control config', e) setConfig(null) - setConfigError('Failed to load bank config') + setConfigError('Failed to load control config') }) const patchConfig = (patch) => { diff --git a/webui/src/Buttons/Presets.jsx b/webui/src/Buttons/Presets.jsx index 54c792f4d1..0ad82b840a 100644 --- a/webui/src/Buttons/Presets.jsx +++ b/webui/src/Buttons/Presets.jsx @@ -233,7 +233,7 @@ function PresetIconPreview({ preset, connectionId, ...childProps }) { setPreviewImage(img) }) .catch((e) => { - console.error('Failed to preview bank') + console.error('Failed to preview control') setPreviewError(true) }) }, [preset.id, socket, connectionId, retryToken]) diff --git a/webui/src/Controls/ActionSetEditor.jsx b/webui/src/Controls/ActionSetEditor.jsx index 3aa773aa9b..e4105866d4 100644 --- a/webui/src/Controls/ActionSetEditor.jsx +++ b/webui/src/Controls/ActionSetEditor.jsx @@ -48,7 +48,7 @@ export function ControlActionSetEditor({ (actionId, key, val) => { socketEmitPromise(socket, 'controls:action:set-option', [controlId, stepId, setId, actionId, key, val]).catch( (e) => { - console.error('Failed to set bank action option', e) + console.error('Failed to set control action option', e) } ) }, @@ -57,7 +57,7 @@ export function ControlActionSetEditor({ const emitSetDelay = useCallback( (actionId, delay) => { socketEmitPromise(socket, 'controls:action:set-delay', [controlId, stepId, setId, actionId, delay]).catch((e) => { - console.error('Failed to set bank action delay', e) + console.error('Failed to set control action delay', e) }) }, [socket, controlId, stepId, setId] @@ -66,7 +66,7 @@ export function ControlActionSetEditor({ const emitDelete = useCallback( (actionId) => { socketEmitPromise(socket, 'controls:action:remove', [controlId, stepId, setId, actionId]).catch((e) => { - console.error('Failed to remove bank action', e) + console.error('Failed to remove control action', e) }) }, [socket, controlId, stepId, setId] @@ -74,7 +74,7 @@ export function ControlActionSetEditor({ const emitDuplicate = useCallback( (actionId) => { socketEmitPromise(socket, 'controls:action:duplicate', [controlId, stepId, setId, actionId]).catch((e) => { - console.error('Failed to duplicate bank action', e) + console.error('Failed to duplicate control action', e) }) }, [socket, controlId, stepId, setId] @@ -83,7 +83,7 @@ export function ControlActionSetEditor({ const emitLearn = useCallback( (actionId) => { socketEmitPromise(socket, 'controls:action:learn', [controlId, stepId, setId, actionId]).catch((e) => { - console.error('Failed to learn bank action values', e) + console.error('Failed to learn control action values', e) }) }, [socket, controlId, stepId, setId] @@ -100,7 +100,7 @@ export function ControlActionSetEditor({ dropSetId, dropIndex, ]).catch((e) => { - console.error('Failed to reorder bank actions', e) + console.error('Failed to reorder control actions', e) }) }, [socket, controlId] @@ -120,7 +120,7 @@ export function ControlActionSetEditor({ const [connectionId, actionId] = actionType.split(':', 2) socketEmitPromise(socket, 'controls:action:add', [controlId, stepId, setId, connectionId, actionId]).catch( (e) => { - console.error('Failed to add bank action', e) + console.error('Failed to add control action', e) } ) }, diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.jsx index d344c99d17..163ef9fab2 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.jsx @@ -111,7 +111,7 @@ export function ControlFeedbacksEditor({ (feedbackType) => { const [connectionId, feedbackId] = feedbackType.split(':', 2) socketEmitPromise(socket, 'controls:feedback:add', [controlId, connectionId, feedbackId]).catch((e) => { - console.error('Failed to add bank feedback', e) + console.error('Failed to add control feedback', e) }) }, [socket, controlId] diff --git a/webui/src/Controls/OptionButtonPreview.jsx b/webui/src/Controls/OptionButtonPreview.jsx index 80940b8109..a0f0a4b2e2 100644 --- a/webui/src/Controls/OptionButtonPreview.jsx +++ b/webui/src/Controls/OptionButtonPreview.jsx @@ -5,7 +5,7 @@ import { SocketContext, socketEmitPromise } from '../util' import { useDeepCompareEffect } from 'use-deep-compare' /** - * Preview a bank based on the selected options + * Preview a button based on the selected options * @param {string} param.location where this preview is located (if any) * @returns */ diff --git a/webui/src/Emulator/Emulator.jsx b/webui/src/Emulator/Emulator.jsx index 6333d5821d..4d5a533286 100644 --- a/webui/src/Emulator/Emulator.jsx +++ b/webui/src/Emulator/Emulator.jsx @@ -239,7 +239,7 @@ function ConfigurePanel({ config }) { // } function CyclePages({ imageCache, setKeyDown, columns, rows }) { - const bankClick = useCallback( + const buttonClick = useCallback( (location, pressed) => { if (pressed) { setKeyDown(location) @@ -288,7 +288,7 @@ function CyclePages({ imageCache, setKeyDown, columns, rows }) { column={x} row={y} preview={imageCache[y]?.[x]} - onClick={bankClick} + onClick={buttonClick} alt={`Button ${formatLocation(location)}`} selected={false} /> diff --git a/webui/src/ImportExport/Import/Page.jsx b/webui/src/ImportExport/Import/Page.jsx index 0a51bc73e4..94a186f024 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.jsx @@ -223,7 +223,7 @@ function ButtonImportPreview({ ...props }) { setPreviewImage(img) }) .catch((e) => { - console.error(`Failed to preview bank: ${e}`) + console.error(`Failed to preview button: ${e}`) }) }, [props.pageNumber, props.column, props.row, socket]) diff --git a/webui/src/TabletView/ButtonsFromPage.jsx b/webui/src/TabletView/ButtonsFromPage.jsx index 7a69b8e48f..d44a5ce96b 100644 --- a/webui/src/TabletView/ButtonsFromPage.jsx +++ b/webui/src/TabletView/ButtonsFromPage.jsx @@ -8,7 +8,7 @@ import { useButtonRenderCache } from '../Hooks/useSharedRenderCache' export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSize, indexOffset }) { const socket = useContext(SocketContext) - const bankClick = useCallback( + const buttonClick = useCallback( (location, pressed) => { socketEmitPromise(socket, 'controls:hot-press', [location, pressed, 'tablet']).catch((e) => console.error(`Hot press failed: ${e}`) @@ -51,7 +51,7 @@ export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSi buttonSize={buttonSize} displayColumn={displayColumn} displayRow={displayRow} - bankClick={bankClick} + buttonClick={buttonClick} /> ) } @@ -65,7 +65,7 @@ export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSi ) } -function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, displayRow, bankClick }) { +function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, displayRow, buttonClick }) { const location = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) const { image } = useButtonRenderCache(location) @@ -85,7 +85,7 @@ function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, dis page={pageNumber} location={location} preview={image} - onClick={bankClick} + onClick={buttonClick} alt={`Button ${formatLocation(location)}`} selected={false} style={buttonStyle} diff --git a/webui/src/Triggers/EditPanel.jsx b/webui/src/Triggers/EditPanel.jsx index 5e9c2e4550..1da5b71568 100644 --- a/webui/src/Triggers/EditPanel.jsx +++ b/webui/src/Triggers/EditPanel.jsx @@ -41,9 +41,9 @@ export function EditTriggerPanel({ controlId }) { setConfigError(null) }) .catch((e) => { - console.error('Failed to load bank config', e) + console.error('Failed to load trigger config', e) setConfig(null) - setConfigError('Failed to load bank config') + setConfigError('Failed to load trigger config') }) const patchConfig = (patch) => { @@ -74,7 +74,7 @@ export function EditTriggerPanel({ controlId }) { socket.off(`controls:runtime-${controlId}`, patchRuntimeProps) socketEmitPromise(socket, 'controls:unsubscribe', [controlId]).catch((e) => { - console.error('Failed to unsubscribe bank config', e) + console.error('Failed to unsubscribe trigger config', e) }) } }, [socket, controlId, reloadConfigToken]) diff --git a/webui/src/Triggers/EventEditor.jsx b/webui/src/Triggers/EventEditor.jsx index 0c5b5e3c3b..1f30e62a1a 100644 --- a/webui/src/Triggers/EventEditor.jsx +++ b/webui/src/Triggers/EventEditor.jsx @@ -60,7 +60,7 @@ export function TriggerEventEditor({ controlId, events, heading }) { const addEvent = useCallback( (eventType) => { socketEmitPromise(socket, 'controls:event:add', [controlId, eventType]).catch((e) => { - console.error('Failed to add bank event', e) + console.error('Failed to add trigger event', e) }) }, [socket, controlId] From c93d184906bed882410e7db2f1fe6ea33017bc08 Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Sun, 12 Nov 2023 01:22:18 +0000 Subject: [PATCH 23/53] chore: update bundled-modules b288387 update ptzoptics-visca to v2.0.2 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index 2ca444e563..b288387803 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 2ca444e56359f487c881bdd049451c216813af7a +Subproject commit b2883878032595aad426781f9c37265375cdd881 From 3c511eb78428ba152b67c4926343a156b808e510 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 14 Nov 2023 15:57:17 +0600 Subject: [PATCH 24/53] fix: LoupedeckLive initialization (#2645) (#2646) --- lib/Surface/USB/LoupedeckLive.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Surface/USB/LoupedeckLive.js b/lib/Surface/USB/LoupedeckLive.js index 62eb116d7e..79823f09be 100644 --- a/lib/Surface/USB/LoupedeckLive.js +++ b/lib/Surface/USB/LoupedeckLive.js @@ -193,7 +193,7 @@ class SurfaceUSBLoupedeckLive extends EventEmitter { this.logger = LogController.createLogger(`Surface/USB/Loupedeck/${devicePath}`) - this.loupedeck = loupedeck + this.#loupedeck = loupedeck this.#modelInfo = modelInfo this.config = { From 5fbded5f7b49bd63923c6d8f95cc78da7e418e84 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 14 Nov 2023 21:00:48 +0000 Subject: [PATCH 25/53] fix: memoize some components to avoid re-renders --- webui/src/Buttons/ButtonInfiniteGrid.jsx | 38 ++++++++++++++------- webui/src/Buttons/EditButton.jsx | 5 +-- webui/src/Components/DropdownInputField.jsx | 6 ++-- webui/src/Constants.js | 6 ++++ webui/src/Controls/ActionSetEditor.jsx | 10 +++--- webui/src/Controls/ButtonStyleConfig.jsx | 12 ++----- webui/src/Controls/FeedbackEditor.jsx | 6 ++-- webui/src/ImportExport/ExportFormat.jsx | 5 +-- webui/src/Triggers/EventEditor.jsx | 6 ++-- 9 files changed, 54 insertions(+), 40 deletions(-) diff --git a/webui/src/Buttons/ButtonInfiniteGrid.jsx b/webui/src/Buttons/ButtonInfiniteGrid.jsx index e0ebd5942b..aa04787cf6 100644 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ b/webui/src/Buttons/ButtonInfiniteGrid.jsx @@ -101,10 +101,8 @@ export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid( selectedButton?.pageNumber === pageNumber && selectedButton?.column === column && selectedButton?.row === row, - style: { - left: (column - minColumn) * tileSize + growWidth, - top: (row - minRow) * tileSize + growHeight, - }, + left: (column - minColumn) * tileSize + growWidth, + top: (row - minRow) * tileSize + growHeight, }) ) } @@ -150,6 +148,14 @@ export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid( window.doGrow = doGrow + const gridCanvasStyle = useMemo( + () => ({ + width: Math.max(countColumns * tileSize, windowSize.width) + growWidth * 2, + height: Math.max(countRows * tileSize, windowSize.height) + growHeight * 2, + }), + [countColumns, countRows, tileSize, windowSize, growWidth, growHeight] + ) + return (
-
+
{doGrow && ( <>
@@ -238,13 +238,27 @@ export const ButtonGridIcon = memo(function ButtonGridIcon({ ...props }) { return }) -export const ButtonGridIconBase = memo(function ButtonGridIcon({ pageNumber, column, row, image, ...props }) { +export const ButtonGridIconBase = memo(function ButtonGridIcon({ + pageNumber, + column, + row, + image, + left, + top, + style, + ...props +}) { const location = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) const title = formatLocation(location) return ( ) -} +}) function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryActions, feedbacks }) { const socket = useContext(SocketContext) diff --git a/webui/src/Components/DropdownInputField.jsx b/webui/src/Components/DropdownInputField.jsx index 1ab8d4c0e5..d5180b51b2 100644 --- a/webui/src/Components/DropdownInputField.jsx +++ b/webui/src/Components/DropdownInputField.jsx @@ -1,13 +1,13 @@ import classNames from 'classnames' import { createContext } from 'react' import { useContext } from 'react' -import { useMemo, useEffect, useCallback } from 'react' +import { useMemo, useEffect, useCallback, memo } from 'react' import Select from 'react-select' import CreatableSelect from 'react-select/creatable' export const MenuPortalContext = createContext(null) -export function DropdownInputField({ +export const DropdownInputField = memo(function DropdownInputField({ choices, allowCustom, minSelection, @@ -193,4 +193,4 @@ export function DropdownInputField({ )}
) -} +}) diff --git a/webui/src/Constants.js b/webui/src/Constants.js index 37e70da406..63eb509f0d 100644 --- a/webui/src/Constants.js +++ b/webui/src/Constants.js @@ -13,4 +13,10 @@ export const FONT_SIZES = [ { id: '44', label: '44pt' }, ] +export const SHOW_HIDE_TOP_BAR = [ + { id: 'default', label: 'Follow Default' }, + { id: true, label: 'Show' }, + { id: false, label: 'Hide' }, +] + export const PRIMARY_COLOR = '#d50215' diff --git a/webui/src/Controls/ActionSetEditor.jsx b/webui/src/Controls/ActionSetEditor.jsx index e4105866d4..2ff6f1a630 100644 --- a/webui/src/Controls/ActionSetEditor.jsx +++ b/webui/src/Controls/ActionSetEditor.jsx @@ -8,7 +8,7 @@ import { faFolderOpen, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { NumberInputField } from '../Components' import { ActionsContext, @@ -30,7 +30,7 @@ import CSwitch from '../CSwitch' import { OptionButtonPreview } from './OptionButtonPreview' import { MenuPortalContext } from '../Components/DropdownInputField' -export function ControlActionSetEditor({ +export const ControlActionSetEditor = memo(function ControlActionSetEditor({ controlId, location, stepId, @@ -171,9 +171,9 @@ export function ControlActionSetEditor({
) -} +}) -function AddActionsPanel({ addPlaceholder, addAction }) { +const AddActionsPanel = memo(function AddActionsPanel({ addPlaceholder, addAction }) { const addActionsRef = useRef(null) const showAddModal = useCallback(() => { if (addActionsRef.current) { @@ -193,7 +193,7 @@ function AddActionsPanel({ addPlaceholder, addAction }) {
) -} +}) export function ActionsList({ location, diff --git a/webui/src/Controls/ButtonStyleConfig.jsx b/webui/src/Controls/ButtonStyleConfig.jsx index 0e3b22bfe8..3a3e4871b6 100644 --- a/webui/src/Controls/ButtonStyleConfig.jsx +++ b/webui/src/Controls/ButtonStyleConfig.jsx @@ -2,7 +2,7 @@ import { CButton, CRow, CCol, CButtonGroup, CForm, CAlert, CInputGroup, CInputGr import React, { useCallback, useContext, useMemo, useState } from 'react' import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' import { AlignmentInputField, ColorInputField, DropdownInputField, PNGInputField, TextInputField } from '../Components' -import { FONT_SIZES } from '../Constants' +import { FONT_SIZES, SHOW_HIDE_TOP_BAR } from '../Constants' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, faFont, faQuestionCircle, faTrash } from '@fortawesome/free-solid-svg-icons' @@ -206,15 +206,7 @@ export function ButtonStyleConfigFields({ {showField2('show_topbar') && (
- +
)} diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.jsx index 163ef9fab2..6870c564ec 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.jsx @@ -9,7 +9,7 @@ import { faQuestionCircle, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { FeedbacksContext, ConnectionsContext, @@ -621,7 +621,7 @@ const noOptionsMessage = ({ inputValue }) => { } } -function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { +const AddFeedbackDropdown = memo(function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { const recentFeedbacksContext = useContext(RecentFeedbacksContext) const menuPortal = useContext(MenuPortalContext) const feedbacksContext = useContext(FeedbacksContext) @@ -694,4 +694,4 @@ function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { noOptionsMessage={noOptionsMessage} /> ) -} +}) diff --git a/webui/src/ImportExport/ExportFormat.jsx b/webui/src/ImportExport/ExportFormat.jsx index bc56ed03ce..b3df04e8b9 100644 --- a/webui/src/ImportExport/ExportFormat.jsx +++ b/webui/src/ImportExport/ExportFormat.jsx @@ -1,3 +1,4 @@ +import React, { memo } from 'react' import { DropdownInputField } from '../Components/DropdownInputField' export const ExportFormatDefault = 'json-gz' @@ -12,6 +13,6 @@ const formatOptions = [ }, ] -export function SelectExportFormat({ value, setValue }) { +export const SelectExportFormat = memo(function SelectExportFormat({ value, setValue }) { return -} +}) diff --git a/webui/src/Triggers/EventEditor.jsx b/webui/src/Triggers/EventEditor.jsx index 1f30e62a1a..29585b20b9 100644 --- a/webui/src/Triggers/EventEditor.jsx +++ b/webui/src/Triggers/EventEditor.jsx @@ -1,7 +1,7 @@ import { CButton, CForm, CButtonGroup, CSwitch } from '@coreui/react' import { faSort, faTrash, faCompressArrowsAlt, faExpandArrowsAlt, faCopy } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { MyErrorBoundary, socketEmitPromise, @@ -355,7 +355,7 @@ const noOptionsMessage = ({ inputValue }) => { return 'No events found' } -function AddEventDropdown({ onSelect }) { +const AddEventDropdown = memo(function AddEventDropdown({ onSelect }) { const menuPortal = useContext(MenuPortalContext) const EventDefinitions = useContext(EventDefinitionsContext) @@ -400,4 +400,4 @@ function AddEventDropdown({ onSelect }) { noOptionsMessage={noOptionsMessage} /> ) -} +}) From 5b6f618afc13baa4a7dcfa10fd89bf737a030a0e Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Wed, 15 Nov 2023 12:35:19 +0000 Subject: [PATCH 26/53] chore: update bundled-modules 7a400a6 update figure53-qlab-advance to v2.4.4 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index b288387803..7a400a6732 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit b2883878032595aad426781f9c37265375cdd881 +Subproject commit 7a400a6732625503e851820561de266fe06fc99a From 66ccd860500d5289d6cfb2fece50e6f4a61da339 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 15 Nov 2023 21:18:39 +0000 Subject: [PATCH 27/53] feat: push png dataUrl to client instead of bmp --- lib/Graphics/Controller.js | 8 ++--- lib/Graphics/Image.js | 16 ++++++++++ lib/Graphics/ImageResult.js | 56 ++++++---------------------------- lib/Graphics/Renderer.js | 18 ++++++----- lib/Surface/IP/ElgatoPlugin.js | 18 +---------- 5 files changed, 40 insertions(+), 76 deletions(-) diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index 54b90c89fc..6ab024be74 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -145,13 +145,13 @@ class GraphicsController extends CoreBase { render = this.#renderLRUCache.get(key) if (!render) { - const { buffer, draw_style } = await this.#pool.exec('drawBankImage', [ + const { buffer, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [ this.#drawOptions, buttonStyle, location, pagename, ]) - render = GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, buttonStyle) + render = GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, buttonStyle) } } else { render = GraphicsRenderer.drawBlank(this.#drawOptions, location) @@ -240,8 +240,8 @@ class GraphicsController extends CoreBase { size: buttonStyle.size === 'auto' ? 'auto' : Number(buttonStyle.size), } - const { buffer, draw_style } = await this.#pool.exec('drawBankImage', [this.#drawOptions, drawStyle]) - return GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, drawStyle) + const { buffer, dataUrl, draw_style } = await this.#pool.exec('drawBankImage', [this.#drawOptions, drawStyle]) + return GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) } /** diff --git a/lib/Graphics/Image.js b/lib/Graphics/Image.js index 77c8d2bf4b..5a7a235451 100644 --- a/lib/Graphics/Image.js +++ b/lib/Graphics/Image.js @@ -989,6 +989,22 @@ class Image { //const buffer = this.canvas.toBuffer('png') return buffer } + + /** + * returns the image as a data-url + * @returns {Promise} + */ + toDataURL() { + return this.canvas.toDataURL('png') + } + + /** + * returns the image as a data-url + * @returns {string} + */ + toDataURLSync() { + return this.canvas.toDataURLSync('png') + } } export default Image diff --git a/lib/Graphics/ImageResult.js b/lib/Graphics/ImageResult.js index a64d72670e..3f191c999d 100644 --- a/lib/Graphics/ImageResult.js +++ b/lib/Graphics/ImageResult.js @@ -5,10 +5,10 @@ export class ImageResult { /** * Image data-url for socket.io clients - * @type {string | null} + * @type {string} * @access private */ - #dataUrl = null + #dataUrl /** * Image pixel buffer @@ -28,61 +28,23 @@ export class ImageResult { /** * @param {Buffer} buffer + * @param {string} dataUrl * @param {ImageResultStyle | undefined} style */ - constructor(buffer, style) { + constructor(buffer, dataUrl, style) { this.buffer = buffer + this.#dataUrl = dataUrl this.style = style this.updated = Date.now() } - get asDataUrl() { - if (!this.#dataUrl && this.buffer) { - const imageSize = Math.sqrt(this.buffer.length / 4) - const bmpHeader = this.#createBmpHeader(imageSize, imageSize) - - this.#dataUrl = 'data:image/bmp;base64,' + Buffer.concat([bmpHeader, this.buffer]).toString('base64') - } - - return this.#dataUrl - } - /** - * Creates a BMP image header for the given size - * assuming RGBA channel order, 32Bit/Pixel, starting with top left pixel - * @param {number} imageWidth - * @param {number} imageHeight - * @returns {Buffer} buffer containing the header + * Get the image as a data url which can be used by a web base client + * @returns {string} */ - #createBmpHeader(imageWidth = 72, imageHeight = 72) { - const dataLength = imageWidth * imageHeight * 4 - const bmpHeaderSize = 138 - const bmpHeader = Buffer.alloc(bmpHeaderSize, 0) - // file header - bmpHeader.write('BM', 0, 2) // flag - bmpHeader.writeUInt32LE(dataLength + bmpHeaderSize, 2) // filesize - bmpHeader.writeUInt32LE(0, 6) // reserved - bmpHeader.writeUInt32LE(bmpHeaderSize, 10) // data start - // image header - bmpHeader.writeUInt32LE(bmpHeaderSize - 14, 14) // header info size - bmpHeader.writeUInt32LE(imageWidth, 18) // width - bmpHeader.writeInt32LE(imageHeight * -1, 22) // height - bmpHeader.writeUInt16LE(1, 26) // planes - bmpHeader.writeUInt16LE(32, 28) // bits per pixel - bmpHeader.writeUInt32LE(3, 30) // compress - bmpHeader.writeUInt32LE(dataLength, 34) // data size - bmpHeader.writeUInt32LE(Math.round(39.375 * imageWidth), 38) // hr - bmpHeader.writeUInt32LE(Math.round(39.375 * imageHeight), 42) // vr - bmpHeader.writeUInt32LE(0, 46) // colors - bmpHeader.writeUInt32LE(0, 50) // importantColors - bmpHeader.writeUInt32LE(0x000000ff, 54) // Red Bitmask - bmpHeader.writeUInt32LE(0x0000ff00, 58) // Green Bitmask - bmpHeader.writeUInt32LE(0x00ff0000, 62) // Blue Bitmask - bmpHeader.writeUInt32LE(0xff000000, 66) // Alpha Bitmask - bmpHeader.write('BGRs', 70, 4) // colorspace - - return bmpHeader + get asDataUrl() { + return this.#dataUrl } get bgcolor() { diff --git a/lib/Graphics/Renderer.js b/lib/Graphics/Renderer.js index 95f2c3aa15..11c53a5092 100644 --- a/lib/Graphics/Renderer.js +++ b/lib/Graphics/Renderer.js @@ -62,7 +62,7 @@ export default class GraphicsRenderer { img.horizontalLine(13.5, 'rgb(30, 30, 30)') } // console.timeEnd('drawBlankImage') - return new ImageResult(img.buffer(), undefined) + return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) } /** @@ -75,27 +75,28 @@ export default class GraphicsRenderer { * @returns {Promise} Image render object */ static async drawButtonImage(options, drawStyle, location, pagename) { - const { buffer, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( + const { buffer, dataUrl, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( options, drawStyle, location, pagename ) - return GraphicsRenderer.wrapDrawButtonImage(buffer, draw_style, drawStyle) + return GraphicsRenderer.wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) } /** * * @param {Buffer} buffer + * @param {string} dataUrl * @param {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle * @returns */ - static wrapDrawButtonImage(buffer, draw_style, drawStyle) { + static wrapDrawButtonImage(buffer, dataUrl, draw_style, drawStyle) { const draw_style2 = draw_style === 'button' ? (drawStyle.style === 'button' ? drawStyle : undefined) : draw_style - return new ImageResult(buffer, draw_style2) + return new ImageResult(buffer, dataUrl, draw_style2) } /** @@ -105,7 +106,7 @@ export default class GraphicsRenderer { * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public - * @returns {Promise<{ buffer: Buffer, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object + * @returns {Promise<{ buffer: Buffer, dataUrl: string, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object */ static async drawButtonImageUnwrapped(options, drawStyle, location, pagename) { // console.log('starting drawBankImage '+ performance.now()) @@ -180,6 +181,7 @@ export default class GraphicsRenderer { // console.timeEnd('drawBankImage') return { buffer: img.buffer(), + dataUrl: await img.toDataURL(), draw_style, } } @@ -370,7 +372,7 @@ export default class GraphicsRenderer { const img = new Image(72, 72, 3) img.fillColor(colorDarkGrey) img.drawTextLineAligned(36, 36, `${num}`, colorWhite, 44, 'center', 'center') - return new ImageResult(img.buffer(), undefined) + return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) } /** @@ -386,6 +388,6 @@ export default class GraphicsRenderer { img.drawAlignedText(0, 15, 72, 72, code.replace(/[a-z0-9]/gi, '*'), colorWhite, 18, 'center', 'center') } - return new ImageResult(img.buffer(), undefined) + return new ImageResult(img.buffer(), img.toDataURLSync(), undefined) } } diff --git a/lib/Surface/IP/ElgatoPlugin.js b/lib/Surface/IP/ElgatoPlugin.js index d54f292071..559a487f0a 100644 --- a/lib/Surface/IP/ElgatoPlugin.js +++ b/lib/Surface/IP/ElgatoPlugin.js @@ -20,7 +20,6 @@ import { EventEmitter } from 'events' import ImageWriteQueue from '../../Resources/ImageWriteQueue.js' import imageRs from '@julusian/image-rs' import { translateRotation } from '../../Resources/Util.js' -import { PNG } from 'pngjs' import { oldBankIndexToXY, xyToOldBankIndex } from '../../Shared/ControlId.js' import { convertPanelIndexToXY } from '../Util.js' import { LEGACY_MAX_BUTTONS } from '../../Util/Constants.js' @@ -222,25 +221,10 @@ class SurfaceIPElgatoPlugin extends EventEmitter { const key = xyToOldBankIndex(x, y) if (key) { if (this.#supportsPng) { - const imagesize = Math.sqrt(render.buffer.length / 4) // TODO: assuming here that the image is square - - const png = new PNG({ - width: imagesize, - height: imagesize, - bgColor: { - red: 0, - green: 0, - blue: 0, - }, - }) - png.data = render.buffer - - const pngBuffer = PNG.sync.write(png) - this.socket.apicommand('fillImage', { keyIndex: key, png: true, - data: 'data:image/png;base64,' + pngBuffer.toString('base64'), + data: render.asDataUrl, }) } else { this.write_queue.queue(key, render.buffer) From 17fda46158bc90600d345c3c634969363eebd3f6 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 15 Nov 2023 22:19:11 +0100 Subject: [PATCH 28/53] feat: support grid size in protocols (#2639) --- docs/5_remote_control/http_remote_control.md | 88 +- docs/5_remote_control/osc_control.md | 65 +- docs/5_remote_control/tcp_udp.md | 61 +- lib/@types/osc.d.ts | 7 +- lib/Data/Model/UserConfigModel.ts | 6 + lib/Data/UserConfig.js | 31 +- lib/Instance/CustomVariable.js | 10 + lib/Resources/Util.js | 20 + lib/Service/Api.js | 212 ---- lib/Service/Controller.js | 9 +- lib/Service/EmberPlus.js | 325 +++++- lib/Service/HttpApi.js | 617 +++++++++++ lib/Service/OscApi.js | 420 ++++++++ lib/Service/OscListener.js | 112 +- lib/Service/Tcp.js | 10 +- lib/Service/TcpUdpApi.js | 549 ++++++++++ lib/Service/Udp.js | 10 +- lib/UI/Express.js | 189 +--- package.json | 3 + test/Service/HttpApi.test.js | 1018 ++++++++++++++++++ test/Service/OscApi.test.js | 815 ++++++++++++++ test/Service/Rosstalk.test.js | 109 ++ test/Service/TcpUdpApi.test.js | 790 ++++++++++++++ tools/dev.mjs | 2 +- webui/src/UserConfig/HttpConfig.jsx | 57 + webui/src/UserConfig/HttpProtocol.jsx | 114 +- webui/src/UserConfig/OscConfig.jsx | 22 + webui/src/UserConfig/OscProtocol.jsx | 114 +- webui/src/UserConfig/TcpConfig.jsx | 22 + webui/src/UserConfig/TcpUdpProtocol.jsx | 152 ++- webui/src/UserConfig/UdpConfig.jsx | 22 + webui/src/UserConfig/index.jsx | 2 + yarn.lock | 113 +- 33 files changed, 5439 insertions(+), 657 deletions(-) delete mode 100644 lib/Service/Api.js create mode 100644 lib/Service/HttpApi.js create mode 100644 lib/Service/OscApi.js create mode 100644 lib/Service/TcpUdpApi.js create mode 100644 test/Service/HttpApi.test.js create mode 100644 test/Service/OscApi.test.js create mode 100644 test/Service/Rosstalk.test.js create mode 100644 test/Service/TcpUdpApi.test.js create mode 100644 webui/src/UserConfig/HttpConfig.jsx diff --git a/docs/5_remote_control/http_remote_control.md b/docs/5_remote_control/http_remote_control.md index c085ac52c9..54f801145a 100644 --- a/docs/5_remote_control/http_remote_control.md +++ b/docs/5_remote_control/http_remote_control.md @@ -2,6 +2,80 @@ Remote triggering can be done by sending `HTTP` Requests to the same IP and port **Commands** +This API tries to follow REST principles, and the convention that a `POST` request will modify a value, and a `GET` request will retrieve values. + +- Press and release a button (run both down and up actions) + Method: POST + Path: `/api/location////press` +- Press the button (run down actions and hold) + Method: POST + Path: `/api/location////down` +- Release the button (run up actions) + Method: POST + Path: `/api/location////up` +- Trigger a left rotation of the button/encoder + Method: POST + Path: `/api/location////rotate-left` +- Trigger a right rotation of the button/encoder + Method: POST + Path: `/api/location////rotate-right` +- Set the current step of a button/encoder + Method: POST + Path: `/api/location////step` + +- Change background color of button + Method: POST + Path: `/api/location////style?bgcolor=` +- Change background color of button + Method: POST + Path: `/api/location////style` + Body: `{ "bgcolor": "" }` OR `{ "bgcolor": "rgb(,,)" }` +- Change text color of button + Method: POST + Path: `/api/location////style?color=` +- Change text color of button + Method: POST + Path: `/api/location////style` + Body: `{ "color": "" }` OR `{ "color": "rgb(,,)" }` +- Change text of button + Method: POST + Path: `/api/location////style?text=` +- Change text color of button + Method: POST + Path: `/api/location////style` + Body: `{ "text": "" }` + +- Change custom variable value + Method: POST + Path: `/api/custom-variable//value?value=` +- Change custom variable value + Method: POST + Path: `/api/custom-variable//value` + Body: `` +- Get custom variable value + Method: GET + Path: `/api/custom-variable//value` +- Rescan for USB surfaces + Method: POST + Path: `/surfaces/rescan` + +**Examples** +Press page 1 row 0 column 2: +POST `/api/location/1/0/2/press` + +Change the text of row 0 column 4 on page 2 to TEST: +POST `/api/location/1/0/4/style?text=TEST` + +Change the text of row 1, column 4 on page 2 to TEST, background color to #ffffff, text color to #000000 and font size to 28px: +POST `/api/location/2/1/4/style` with body `{ "text": "TEST", "bgcolor": "#ffffff", "color": "#000000", "size": 28 }` + +Change custom variable "cue" to value "intro": +POST `/api/custom-variable/cue/value?value=intro` + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + - `/press/bank//` _Press and release a button (run both down and up actions)_ - `/press/bank///down` @@ -20,17 +94,3 @@ Remote triggering can be done by sending `HTTP` Requests to the same IP and port _Change custom variable value_ - `/rescan` _Make Companion rescan for newly attached USB surfaces_ - - -**Examples** -Press page 1 bank 2: -`/press/bank/1/2` - -Change the text of button 4 on page 2 to TEST: -`/style/bank/2/4/?text=TEST` - -Change the text of button 4 on page 2 to TEST, background color to #ffffff, text color to #000000 and font size to 28px: -`/style/bank/2/4/?text=TEST&bgcolor=%23ffffff&color=%23000000&size=28px` - -Change custom variable "cue" to value "intro": -`/set/custom-variable/cue?value=intro` diff --git a/docs/5_remote_control/osc_control.md b/docs/5_remote_control/osc_control.md index e03fc03260..0f8dd0cfb9 100644 --- a/docs/5_remote_control/osc_control.md +++ b/docs/5_remote_control/osc_control.md @@ -1,34 +1,67 @@ -Remote triggering can be done by sending OSC commands to port `12321`. +Remote triggering can be done by sending OSC commands to port `12321` (the port number is configurable). **Commands** -- `/press/bank/ ` +- `/location////press` _Press and release a button (run both down and up actions)_ -- `/press/bank/ <1>` +- `/location////down` _Press the button (run down actions and hold)_ -- `/press/bank/ <0>` +- `/location////up` _Release the button (run up actions)_ -- `/style/bgcolor/ ` +- `/location////rotate-left` + _Trigger a left rotation of the button/encoder_ +- `/location////rotate-right` + _Trigger a right rotation of the button/encoder_ +- `/location////step` + _Set the current step of a button/encoder_ + +- `/location////style/bgcolor ` _Change background color of button_ -- `/style/color/ ` +- `/location////style/bgcolor ` + _Change background color of button_ +- `/location////style/color ` _Change color of text on button_ -- `/style/text/ ` +- `/location////style/color ` + _Change color of text on button_ +- `/location////style/text ` _Change text on a button_ + - `/custom-variable//value ` _Change custom variable value_ -- `/rescan 1` - _Make Companion rescan for newly attached USB surfaces_ +- `/surfaces/rescan` + _Rescan for USB surfaces_ **Examples** -Press button 5 on page 1 down and hold -`/press/bank/1/5 1` +Press row 0, column 5 on page 1 down and hold +`/location/1/0/5/press` -Change button background color of button 5 on page 1 to red -`/style/bgcolor/1/5 255 0 0` +Change button background color of row 0, column 5 on page 1 to red +`/location/1/0/5/style/bgcolor 255 0 0` +`/location/1/0/5/style/bgcolor rgb(255,0,0)` +`/location/1/0/5/style/bgcolor #ff0000` -Change the text of button 5 on page 1 to ONLINE -`/style/text/1/5 ONLINE` +Change the text of row 0, column 5 on page 1 to ONLINE +`/location/1/0/5/style/text ONLINE` Change custom variable "cue" to value "intro": -`/custom-variable/cue intro` +`/custom-variable/cue/value intro` + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + +- `/press/bank/ ` + _Press and release a button (run both down and up actions)_ +- `/press/bank/ <1>` + _Press the button (run down actions and hold)_ +- `/press/bank/ <0>` + _Release the button (run up actions)_ +- `/style/bgcolor/ ` + _Change background color of button_ +- `/style/color/ ` + _Change color of text on button_ +- `/style/text/ ` + _Change text on a button_ +- `/rescan 1` + _Make Companion rescan for newly attached USB surfaces_ diff --git a/docs/5_remote_control/tcp_udp.md b/docs/5_remote_control/tcp_udp.md index f183d3bd63..2cb8585419 100644 --- a/docs/5_remote_control/tcp_udp.md +++ b/docs/5_remote_control/tcp_udp.md @@ -2,6 +2,54 @@ Remote triggering can be done by sending TCP (port `51234`) or UDP (port `51235` **Commands** +- `SURFACE PAGE-SET ` + _Set a surface to a specific page_ +- `SURFACE PAGE-UP` + _Page up on a specific surface_ +- `SURFACE PAGE-DOWN` + _Page down on a specific surface_ + +- `LOCATION // PRESS` + _Press and release a button (run both down and up actions)_ +- `LOCATION // DOWN` + _Press the button (run down actions)_ +- `LOCATION // UP` + _Release the button (run up actions)_ +- `LOCATION // ROTATE-LEFT` + _Trigger a left rotation of the button/encoder_ +- `LOCATION // ROTATE-RIGHT` + _Trigger a right rotation of the button/encoder_ +- `LOCATION // SET-STEP ` + _Set the current step of a button/encoder_ + +- `LOCATION // STYLE TEXT ` + _Change text on a button_ +- `LOCATION // STYLE COLOR ` + _Change text color on a button (#000000)_ +- `LOCATION // STYLE BGCOLOR ` + _Change background color on a button (#000000)_ + +- `CUSTOM-VARIABLE SET-VALUE ` + _Change custom variable value_ +- `SURFACES RESCAN` + _Make Companion rescan for USB surfaces_ + + +**Examples** +Set the emulator surface to page 23: +`SURFACE emulator PAGE-SET 23` + +Press page 1 row 2 column 3: +`LOCATION 1/2/3 PRESS` + +Change custom variable "cue" to value "intro": +`CUSTOM-VARIABLE cue SET-VALUE intro` + + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + - `PAGE-SET ` _Make device go to a specific page_ - `PAGE-UP ` @@ -20,18 +68,5 @@ Remote triggering can be done by sending TCP (port `51234`) or UDP (port `51235` _Change text color on a button (#000000)_ - `STYLE BANK BGCOLOR ` _Change background color on a button (#000000)_ -- `CUSTOM-VARIABLE SET-VALUE ` - _Change custom variable value_ - `RESCAN` _Make Companion rescan for newly attached USB surfaces_ - - -**Examples** -Set the emulator surface to page 23: -`PAGE-SET 23 emulator` - -Press page 1 bank 2: -`BANK-PRESS 1 2` - -Change custom variable "cue" to value "intro": -`CUSTOM-VARIABLE cue SET-VALUE intro` diff --git a/lib/@types/osc.d.ts b/lib/@types/osc.d.ts index c270ecf402..64b38ba751 100644 --- a/lib/@types/osc.d.ts +++ b/lib/@types/osc.d.ts @@ -47,6 +47,11 @@ declare module 'osc' { args: Argument | Array | MetaArgument | Array } + export interface OscReceivedMessage { + address: string + args: Array + } + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OscBundle {} @@ -59,7 +64,7 @@ declare module 'osc' { export interface PortEvents { ready: () => void - message: (message: OscMessage, timeTag: number | undefined, info: SenderInfo) => void + message: (message: OscReceivedMessage, timeTag: number | undefined, info: SenderInfo) => void bundle: (bundle: OscBundle, timeTag: number, info: SenderInfo) => void osc: (packet: OscBundle | OscMessage, info: SenderInfo) => void raw: (data: Uint8Array, info: SenderInfo) => void diff --git a/lib/Data/Model/UserConfigModel.ts b/lib/Data/Model/UserConfigModel.ts index e19c0cde27..d92442ce2a 100644 --- a/lib/Data/Model/UserConfigModel.ts +++ b/lib/Data/Model/UserConfigModel.ts @@ -19,14 +19,20 @@ export interface UserConfigModel { pin: string pin_timeout: number + http_api_enabled: boolean + http_legacy_api_enabled: boolean + tcp_enabled: boolean tcp_listen_port: number + tcp_legacy_api_enabled: boolean udp_enabled: boolean udp_listen_port: number + udp_legacy_api_enabled: boolean osc_enabled: boolean osc_listen_port: number + osc_legacy_api_enabled: boolean rosstalk_enabled: boolean diff --git a/lib/Data/UserConfig.js b/lib/Data/UserConfig.js index f24837fe53..e029913b75 100644 --- a/lib/Data/UserConfig.js +++ b/lib/Data/UserConfig.js @@ -49,14 +49,20 @@ class DataUserConfig extends CoreBase { pin: '', pin_timeout: 0, + http_api_enabled: true, + http_legacy_api_enabled: false, + tcp_enabled: false, tcp_listen_port: 16759, + tcp_legacy_api_enabled: false, udp_enabled: false, udp_listen_port: 16759, + udp_legacy_api_enabled: false, osc_enabled: false, osc_listen_port: 12321, + osc_legacy_api_enabled: false, rosstalk_enabled: false, @@ -117,7 +123,7 @@ class DataUserConfig extends CoreBase { this.data = this.db.getKey('userconfig', cloneDeep(DataUserConfig.Defaults)) - this.checkV2InPlaceUpgrade() + this.#populateMissingForExistingDb() let save = false // copy default values. this will set newly added defaults too @@ -157,7 +163,7 @@ class DataUserConfig extends CoreBase { * For an existing DB we need to check if some new settings are present * @access protected */ - checkV2InPlaceUpgrade() { + #populateMissingForExistingDb() { if (!this.db.getIsFirstRun()) { // This is an existing db, so setup the ports to match how it used to be /** @type {Partial} */ @@ -200,6 +206,27 @@ class DataUserConfig extends CoreBase { if (this.data['usb_hotplug'] === undefined) { this.data['usb_hotplug'] = false } + + // Enable the legacy OSC api if OSC is enabled + if (this.data.osc_enabled && this.data.osc_legacy_api_enabled === undefined) { + this.data.osc_legacy_api_enabled = true + } + + // Enable the legacy TCP api if TCP is enabled + if (this.data.tcp_enabled && this.data.tcp_legacy_api_enabled === undefined) { + this.data.tcp_legacy_api_enabled = true + } + + // Enable the legacy UDP api if UDP is enabled + if (this.data.udp_enabled && this.data.udp_legacy_api_enabled === undefined) { + this.data.udp_legacy_api_enabled = true + } + + // Enable the http api (both modern and legacy) + if (this.data.http_api_enabled === undefined) { + this.data.http_api_enabled = true + this.data.http_legacy_api_enabled = true + } } } diff --git a/lib/Instance/CustomVariable.js b/lib/Instance/CustomVariable.js index 1c956a6091..602ab91f70 100644 --- a/lib/Instance/CustomVariable.js +++ b/lib/Instance/CustomVariable.js @@ -338,6 +338,16 @@ export default class InstanceCustomVariable { } } + /** + * Get the value of a custom variable + * @param {string} name + * @returns {CompanionVariableValue | undefined} + */ + getValue(name) { + const fullname = `${custom_variable_prefix}${name}` + return this.#base.getVariableValue('internal', fullname) + } + /** * Set the value of a custom variable * @param {string} name diff --git a/lib/Resources/Util.js b/lib/Resources/Util.js index ef00c1526e..3d1fe9d83c 100644 --- a/lib/Resources/Util.js +++ b/lib/Resources/Util.js @@ -100,6 +100,26 @@ export const parseColor = (color, skipValidation = false) => { return 'rgba(0, 0, 0, 0)' } +/** + * Parse a css color string to a number + * @param {any} color + * @returns {number | false} + */ +export const parseColorToNumber = (color) => { + if (typeof color === 'string') { + const newColor = colord(color) + if (newColor.isValid()) { + return rgb(newColor.rgba.r, newColor.rgba.g, newColor.rgba.b) + } else { + return false + } + } + if (typeof color === 'number') { + return color + } + return false +} + /** * @param {number} milliseconds */ diff --git a/lib/Service/Api.js b/lib/Service/Api.js deleted file mode 100644 index d9220d86c0..0000000000 --- a/lib/Service/Api.js +++ /dev/null @@ -1,212 +0,0 @@ -import CoreBase from '../Core/Base.js' -import RegexRouter from './RegexRouter.js' - -/** - * Common API command processing for {@link ServiceTcp} and {@link ServiceUdp}. - * - * @extends CoreBase - * @author Håkon Nessjøen - * @author Keith Rocheck - * @author William Viker - * @author Julian Waller - * @since 1.3.0 - * @copyright 2022 Bitfocus AS - * @license - * 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. - */ -class ServiceApi extends CoreBase { - /** - * Message router - * @type {RegexRouter} - * @access private - */ - #router - - /** - * @param {import('../Registry.js').default} registry - the core registry - */ - constructor(registry) { - super(registry, 'api', 'Service/Api') - - this.#router = new RegexRouter(() => { - throw new ApiMessageError('Syntax error') - }) - this.#setupRoutes() - } - - #setupRoutes() { - this.#router.addPath('page-set :page(\\d+) :surfaceId', (match) => { - const page = parseInt(match.page) - const surfaceId = match.surfaceId - - this.surfaces.devicePageSet(surfaceId, page) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('page-up :surfaceId', (match) => { - const surfaceId = match.surfaceId - - this.surfaces.devicePageUp(surfaceId) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('page-down :surfaceId', (match) => { - const surfaceId = match.surfaceId - - this.surfaces.devicePageDown(surfaceId) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('bank-press :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-press (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, true, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - - setTimeout(() => { - this.logger.info(`Auto releasing bank-press ${controlId}`) - this.controls.pressControl(controlId, false, undefined) - }, 20) - }) - - this.#router.addPath('bank-down :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-down (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, true, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - }) - - this.#router.addPath('bank-up :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-up (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, false, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - }) - - this.#router.addPath('bank-step :page(\\d+) :bank(\\d+) :step(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const step = Number(match.step) - - this.logger.info(`Got bank-step (trigger) ${controlId} ${step}`) - - if (isNaN(step) || step <= 0) throw new ApiMessageError('Step out of range') - - const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) throw new ApiMessageError('Invalid control') - - if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) text{ :text}?', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - const text = match.text || '' - - control.styleSetFields({ text: text }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) bgcolor #:color([a-f\\d]+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const color = parseInt(match.color, 16) - if (isNaN(color)) throw new ApiMessageError('Invalid color') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - control.styleSetFields({ bgcolor: color }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) color #:color([a-f\\d]+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const color = parseInt(match.color, 16) - if (isNaN(color)) throw new ApiMessageError('Invalid color') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - control.styleSetFields({ color: color }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('rescan', async () => { - this.logger.debug('Rescanning USB') - - try { - await this.surfaces.triggerRefreshDevices() - } catch (e) { - throw new ApiMessageError('Scan failed') - } - }) - - this.#router.addPath('custom-variable :name set-value :value(.*)', async (match) => { - const result = this.instance.variable.custom.setValue(match.name, match.value) - if (result) { - throw new ApiMessageError(result) - } - }) - } - - /** - * Fire an API command from a raw TCP/UDP command - * @param {string} data - the raw command - * @returns {Promise} - */ - async parseApiCommand(data) { - data = data.trim() - this.logger.silly(`API parsing command: ${data}`) - - return this.#router.processMessage(data) - } -} - -export class ApiMessageError extends Error { - /** - * @param {string} message - */ - constructor(message) { - super(message) - } -} - -export default ServiceApi diff --git a/lib/Service/Controller.js b/lib/Service/Controller.js index 960c368e12..a44605c510 100644 --- a/lib/Service/Controller.js +++ b/lib/Service/Controller.js @@ -1,8 +1,8 @@ -import ServiceApi from './Api.js' import ServiceArtnet from './Artnet.js' import ServiceBonjourDiscovery from './BonjourDiscovery.js' import ServiceElgatoPlugin from './ElgatoPlugin.js' import ServiceEmberPlus from './EmberPlus.js' +import { ServiceHttpApi } from './HttpApi.js' import ServiceHttps from './Https.js' import ServiceOscListener from './OscListener.js' import ServiceOscSender from './OscSender.js' @@ -37,13 +37,14 @@ class ServiceController { * @param {import('../Registry.js').default} registry - the application core */ constructor(registry) { + this.httpApi = new ServiceHttpApi(registry, registry.ui.express.legacyApiRouter) + this.httpApi.bindToApp(registry.ui.express.app) // @ts-ignore this.https = new ServiceHttps(registry, registry.ui.express, registry.io) this.oscSender = new ServiceOscSender(registry) this.oscListener = new ServiceOscListener(registry) - this.api = new ServiceApi(registry) - this.tcp = new ServiceTcp(registry, this.api) - this.udp = new ServiceUdp(registry, this.api) + this.tcp = new ServiceTcp(registry) + this.udp = new ServiceUdp(registry) this.emberplus = new ServiceEmberPlus(registry) this.artnet = new ServiceArtnet(registry) this.rosstalk = new ServiceRosstalk(registry) diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index d8ec8d0c10..7487f3e3c4 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -1,13 +1,32 @@ import { EmberServer, Model as EmberModel } from 'emberplus-connection' import { getPath } from 'emberplus-connection/dist/Ember/Lib/util.js' import ServiceBase from './Base.js' -import { xyToOldBankIndex } from '../Shared/ControlId.js' -import { pad } from '../Resources/Util.js' +import { formatLocation, xyToOldBankIndex } from '../Shared/ControlId.js' +import { pad, parseColorToNumber } from '../Resources/Util.js' -const NODE_STATE = 0 -const NODE_TEXT = 1 -const NODE_TEXT_COLOR = 2 -const NODE_BG_COLOR = 3 +// const LOCATION_NODE_CONTROLID = 0 +const LOCATION_NODE_PRESSED = 1 +const LOCATION_NODE_TEXT = 2 +const LOCATION_NODE_TEXT_COLOR = 3 +const LOCATION_NODE_BG_COLOR = 4 + +const LEGACY_NODE_STATE = 0 +const LEGACY_NODE_TEXT = 1 +const LEGACY_NODE_TEXT_COLOR = 2 +const LEGACY_NODE_BG_COLOR = 3 + +/** + * Generate ember+ path + * @param {import('../Data/Model/ExportModel.js').ExportGridSize} gridSize + * @param {import('../Resources/Util.js').ControlLocation} location + * @param {number} node + * @returns {string} + */ +function buildPathForLocation(gridSize, location, node) { + const row = location.row - gridSize.minRow + const column = location.column - gridSize.minColumn + return `0.2.${location.pageNumber}.${row}.${column}.${node}` +} /** * Generate ember+ path @@ -20,12 +39,14 @@ function buildPathForButton(page, bank, node) { return `0.1.${page}.${bank}.${node}` } /** - * Convert numeric color to hex - * @param {number} color + * Convert internal color to hex + * @param {any} color * @returns {string} */ function formatColorAsHex(color) { - return `#${pad(Number(color).toString(16).slice(-6), '0', 6)}` + const newColor = parseColorToNumber(color) + if (newColor === false) return '#000000' + return `#${pad(newColor.toString(16).slice(-6), '0', 6)}` } /** * Parse hex color as number @@ -106,69 +127,67 @@ class ServiceEmberPlus extends ServiceBase { * @access private */ #getPagesTree() { - let pages = this.page.getAll(true) - /** @type {Record>} */ let output = {} - for (let page = 1; page <= 99; page++) { + for (let pageNumber = 1; pageNumber <= 99; pageNumber++) { /** @type {Record>} */ const children = {} for (let bank = 1; bank <= 32; bank++) { - const controlId = this.page.getControlIdAtOldBankIndex(page, bank) - if (!controlId) continue - const control = this.controls.getControl(controlId) + const controlId = this.page.getControlIdAtOldBankIndex(pageNumber, bank) + const control = controlId ? this.controls.getControl(controlId) : undefined - /** @type {any} */ - const drawStyle = control?.getDrawStyle() || {} + /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + let drawStyle = control?.getDrawStyle() || null + if (drawStyle?.style !== 'button') drawStyle = null children[bank] = new EmberModel.NumberedTreeNodeImpl( bank, - new EmberModel.EmberNodeImpl(`Button ${page}.${bank}`), + new EmberModel.EmberNodeImpl(`Button ${pageNumber}.${bank}`), { - [NODE_STATE]: new EmberModel.NumberedTreeNodeImpl( - NODE_STATE, + [LEGACY_NODE_STATE]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_STATE, new EmberModel.ParameterImpl( EmberModel.ParameterType.Boolean, 'State', undefined, - this.#pushedButtons.has(`${page}_${bank}`), + this.#pushedButtons.has(`${pageNumber}_${bank}`), undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( - NODE_TEXT, + [LEGACY_NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_TEXT, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Label', undefined, - drawStyle.text || '', + drawStyle?.text || '', undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( - NODE_TEXT_COLOR, + [LEGACY_NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_TEXT_COLOR, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Text_Color', undefined, - formatColorAsHex(drawStyle.color || 0), + formatColorAsHex(drawStyle?.color || 0), undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( - NODE_BG_COLOR, + [LEGACY_NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_BG_COLOR, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Background_Color', undefined, - formatColorAsHex(drawStyle.bgcolor || 0), + formatColorAsHex(drawStyle?.bgcolor || 0), undefined, undefined, EmberModel.ParameterAccess.ReadWrite @@ -178,9 +197,10 @@ class ServiceEmberPlus extends ServiceBase { ) } - output[page] = new EmberModel.NumberedTreeNodeImpl( - page, - new EmberModel.EmberNodeImpl(pages[page].name === 'PAGE' ? 'Page ' + page : pages[page].name), + const pageName = this.page.getPageName(pageNumber) + output[pageNumber] = new EmberModel.NumberedTreeNodeImpl( + pageNumber, + new EmberModel.EmberNodeImpl(!pageName || pageName === 'PAGE' ? 'Page ' + pageNumber : pageName), children ) } @@ -188,6 +208,125 @@ class ServiceEmberPlus extends ServiceBase { return output } + /** + * Get the locations (page/row/column) structure in EmberModel form + * @returns {Record>} + * @access private + */ + #getLocationTree() { + /** @type {import('../Data/Model/ExportModel.js').ExportGridSize} */ + const gridSize = this.userconfig.getKey('gridSize') + if (!gridSize) return {} + + const rowCount = gridSize.maxRow - gridSize.minRow + 1 + const columnCount = gridSize.maxColumn - gridSize.minColumn + 1 + + /** @type {Record>} */ + const output = {} + + for (let pageNumber = 1; pageNumber <= 99; pageNumber++) { + // TODO - the numbers won't be stable when resizing the `min` grid values + + /** @type {Record>} */ + const pageRows = {} + for (let rowI = 0; rowI < rowCount; rowI++) { + const row = gridSize.minRow + rowI + /** @type {Record>} */ + const rowColumns = {} + + for (let colI = 0; colI < columnCount; colI++) { + const column = gridSize.minColumn + colI + + const location = { + pageNumber, + row, + column, + } + const controlId = this.page.getControlIdAt(location) + const control = controlId ? this.controls.getControl(controlId) : undefined + + /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + let drawStyle = control?.getDrawStyle() || null + if (drawStyle?.style !== 'button') drawStyle = null + + rowColumns[colI] = new EmberModel.NumberedTreeNodeImpl( + colI, + new EmberModel.EmberNodeImpl(`Column ${column}`), + { + // [LOCATION_NODE_CONTROLID]: new EmberModel.NumberedTreeNodeImpl( + // LOCATION_NODE_CONTROLID, + // new EmberModel.ParameterImpl(EmberModel.ParameterType.String, 'Control ID', undefined, controlId ?? '') + // ), + [LOCATION_NODE_PRESSED]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_PRESSED, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.Boolean, + 'Pressed', + undefined, + this.#pushedButtons.has(formatLocation(location)), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_TEXT, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Label', + undefined, + drawStyle?.text || '', + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_TEXT_COLOR, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Text_Color', + undefined, + formatColorAsHex(drawStyle?.color || 0), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_BG_COLOR, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Background_Color', + undefined, + formatColorAsHex(drawStyle?.bgcolor || 0), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + } + ) + } + + pageRows[rowI] = new EmberModel.NumberedTreeNodeImpl( + rowI, + new EmberModel.EmberNodeImpl(`Row ${row}`), + rowColumns + ) + } + + const pageName = this.page.getPageName(pageNumber) + output[pageNumber] = new EmberModel.NumberedTreeNodeImpl( + pageNumber, + new EmberModel.EmberNodeImpl(!pageName || pageName === 'PAGE' ? 'Page ' + pageNumber : pageName), + pageRows + ) + } + + return output + } + /** * Start the service if it is not already running * @access protected @@ -230,6 +369,11 @@ class ServiceEmberPlus extends ServiceBase { ), }), 1: new EmberModel.NumberedTreeNodeImpl(1, new EmberModel.EmberNodeImpl('pages'), this.#getPagesTree()), + 2: new EmberModel.NumberedTreeNodeImpl( + 2, + new EmberModel.EmberNodeImpl('location'), + this.#getLocationTree() + ), }), } @@ -237,6 +381,8 @@ class ServiceEmberPlus extends ServiceBase { this.server.on('error', this.handleSocketError.bind(this)) this.server.onSetValue = this.setValue.bind(this) this.server.init(root) + + this.currentState = true this.logger.info('Listening on port ' + this.port) this.logger.silly('Listening on port ' + this.port) } catch (/** @type {any} */ e) { @@ -262,20 +408,19 @@ class ServiceEmberPlus extends ServiceBase { const node = parseInt(pathInfo[4]) if (isNaN(page) || isNaN(bank) || isNaN(node)) return false - if (page < 0 || page > 100) return false const controlId = this.page.getControlIdAtOldBankIndex(page, bank) if (!controlId) return false switch (node) { - case NODE_STATE: { + case LEGACY_NODE_STATE: { this.logger.silly(`Change button ${controlId} pressed to ${value}`) this.controls.pressControl(controlId, !!value, `emberplus`) this.server?.update(parameter, { value }) return true } - case NODE_TEXT: { + case LEGACY_NODE_TEXT: { this.logger.silly(`Change button ${controlId} text to ${value}`) const control = this.controls.getControl(controlId) @@ -288,7 +433,7 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_TEXT_COLOR: { + case LEGACY_NODE_TEXT_COLOR: { const color = parseHexColor(value + '') this.logger.silly(`Change button ${controlId} text color to ${value} (${color})`) @@ -302,7 +447,72 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_BG_COLOR: { + case LEGACY_NODE_BG_COLOR: { + const color = parseHexColor(value + '') + this.logger.silly(`Change bank ${controlId} background color to ${value} (${color})`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ bgcolor: color }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + } + } else if (pathInfo[0] === '0' && pathInfo[1] === '2' && pathInfo.length === 6) { + const pageNumber = parseInt(pathInfo[2]) + const row = parseInt(pathInfo[3]) + const column = parseInt(pathInfo[4]) + const node = parseInt(pathInfo[5]) + + if (isNaN(pageNumber) || isNaN(row) || isNaN(column) || isNaN(node)) return false + + const controlId = this.page.getControlIdAt({ + pageNumber, + row, + column, + }) + if (!controlId) return false + + switch (node) { + case LOCATION_NODE_PRESSED: { + this.logger.silly(`Change bank ${controlId} pressed to ${value}`) + + this.controls.pressControl(controlId, !!value, `emberplus`) + this.server?.update(parameter, { value }) + return true + } + case LOCATION_NODE_TEXT: { + this.logger.silly(`Change bank ${controlId} text to ${value}`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ text: value }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + case LOCATION_NODE_TEXT_COLOR: { + const color = parseHexColor(value + '') + this.logger.silly(`Change bank ${controlId} text color to ${value} (${color})`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ color: color }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + case LOCATION_NODE_BG_COLOR: { const color = parseHexColor(value + '') this.logger.silly(`Change button ${controlId} background color to ${value} (${color})`) @@ -337,13 +547,15 @@ class ServiceEmberPlus extends ServiceBase { const locationId = `${location.pageNumber}_${bank}` if (pushed && bank) { this.#pushedButtons.add(locationId) + this.#pushedButtons.add(formatLocation(location)) } else { this.#pushedButtons.delete(locationId) + this.#pushedButtons.delete(formatLocation(location)) } if (bank === null) return - this.#updateNodePath(buildPathForButton(location.pageNumber, bank, NODE_STATE), pushed) + this.#updateNodePath(buildPathForButton(location.pageNumber, bank, LEGACY_NODE_STATE), pushed) } /** @@ -355,17 +567,32 @@ class ServiceEmberPlus extends ServiceBase { if (!this.server) return //this.logger.info(`Updating ${page}.${bank} label ${this.banks[page][bank].text}`) + // New 'location' path + const gridSize = this.userconfig.getKey('gridSize') + if (gridSize) { + this.#updateNodePath(buildPathForLocation(gridSize, location, LOCATION_NODE_TEXT), render.style?.text || '') + this.#updateNodePath( + buildPathForLocation(gridSize, location, LOCATION_NODE_TEXT_COLOR), + formatColorAsHex(render.style?.color || 0) + ) + this.#updateNodePath( + buildPathForLocation(gridSize, location, LOCATION_NODE_BG_COLOR), + formatColorAsHex(render.style?.bgcolor || 0) + ) + } + + // Old 'page' path const bank = xyToOldBankIndex(location.column, location.row) if (bank === null) return // Update ember+ with internal state of button - this.#updateNodePath(buildPathForButton(location.pageNumber, bank, NODE_TEXT), render.style?.text || '') + this.#updateNodePath(buildPathForButton(location.pageNumber, bank, LEGACY_NODE_TEXT), render.style?.text || '') this.#updateNodePath( - buildPathForButton(location.pageNumber, bank, NODE_TEXT_COLOR), + buildPathForButton(location.pageNumber, bank, LEGACY_NODE_TEXT_COLOR), formatColorAsHex(render.style?.color || 0) ) this.#updateNodePath( - buildPathForButton(location.pageNumber, bank, NODE_BG_COLOR), + buildPathForButton(location.pageNumber, bank, LEGACY_NODE_BG_COLOR), formatColorAsHex(render.style?.bgcolor || 0) ) } @@ -387,6 +614,20 @@ class ServiceEmberPlus extends ServiceBase { this.server.update(node, { value: newValue }) } } + + /** + * Process an updated userconfig value and enable/disable the module, if necessary. + * @param {string} key - the saved key + * @param {(boolean|number|string)} value - the saved value + * @access public + */ + updateUserConfig(key, value) { + super.updateUserConfig(key, value) + + if (key == 'gridSize') { + this.restartModule() + } + } } export default ServiceEmberPlus diff --git a/lib/Service/HttpApi.js b/lib/Service/HttpApi.js new file mode 100644 index 0000000000..0d3ee79e8e --- /dev/null +++ b/lib/Service/HttpApi.js @@ -0,0 +1,617 @@ +import CoreBase from '../Core/Base.js' +import { ParseAlignment, parseColorToNumber, rgb } from '../Resources/Util.js' +import express from 'express' +import cors from 'cors' +import { formatLocation } from '../Shared/ControlId.js' + +/** + * Class providing the HTTP API. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.2.0 + * @copyright 2022 Bitfocus AS + * @license + * 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. + */ +export class ServiceHttpApi extends CoreBase { + /** + * Root express router + * @type {import('express').Router} + * @access private + */ + #legacyRouter + + /** + * Api router + * @type {import('express').Router} + * @access private + */ + #apiRouter + + /** + * @param {import('../Registry.js').default} registry - the application core + * @param {import('express').Router} router - the http router + */ + constructor(registry, router) { + super(registry, 'http-api', 'Service/HttpApi') + + this.#legacyRouter = router + this.#apiRouter = express.Router() + this.#apiRouter.use(cors()) + + this.#setupLegacyHttpRoutes() + this.#setupNewHttpRoutes() + } + + /** + * + * @param {import('express').Application} app + */ + bindToApp(app) { + app.use( + '/api', + (_req, res, next) => { + // Check that the API is enabled + if (this.userconfig.getKey('http_api_enabled')) { + // Continue + next() + } else { + // Disabled + res.status(403).send() + } + }, + this.#apiRouter + ) + } + + #isLegacyRouteAllowed() { + return !!(this.userconfig.getKey('http_api_enabled') && this.userconfig.getKey('http_legacy_api_enabled')) + } + + #setupLegacyHttpRoutes() { + this.#legacyRouter.options('/press/bank/*', (_req, res, _next) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + return res.send(200) + }) + + this.#legacyRouter.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info(`Got HTTP /press/bank/ (trigger) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + setTimeout(() => { + this.logger.info(`Auto releasing HTTP /press/bank/ page ${req.params.page} button ${req.params.bank}`) + this.registry.controls.pressControl(controlId, false, 'http') + }, 20) + + return res.send('ok') + }) + + this.#legacyRouter.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})/:direction(down|up)', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + if (req.params.direction == 'down') { + this.logger.info(`Got HTTP /press/bank/ (DOWN) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex( + Number(req.params.page), + Number(req.params.bank) + ) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + } else { + this.logger.info(`Got HTTP /press/bank/ (UP) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex( + Number(req.params.page), + Number(req.params.bank) + ) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, false, 'http') + } + + return res.send('ok') + }) + + this.#legacyRouter.get('^/rescan', (_req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info('Got HTTP /rescan') + return this.registry.surfaces.triggerRefreshDevices().then( + () => { + res.send('ok') + }, + () => { + res.send('fail') + } + ) + }) + + this.#legacyRouter.get('^/style/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info(`Got HTTP /style/bank ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + const control = this.registry.controls.getControl(controlId) + + if (!control || !control.supportsStyle) { + res.status(404) + res.send('Not found') + return + } + + const newFields = {} + + if (req.query.bgcolor) { + const value = req.query.bgcolor.replace(/#/, '') + const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) + if (color !== false) { + newFields.bgcolor = color + } + } + + if (req.query.color) { + const value = req.query.color.replace(/#/, '') + const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) + if (color !== false) { + newFields.color = color + } + } + + if (req.query.size) { + const value = req.query.size.replace(/pt/i, '') + newFields.size = value + } + + if (req.query.text || req.query.text === '') { + newFields.text = req.query.text + } + + if (req.query.png64 || req.query.png64 === '') { + if (req.query.png64 === '') { + newFields.png64 = null + } else if (!req.query.png64.match(/data:.*?image\/png/)) { + res.status(400) + res.send('png64 must be a base64 encoded png file') + return + } else { + newFields.png64 = req.query.png64 + } + } + + if (req.query.alignment) { + try { + const [, , alignment] = ParseAlignment(req.query.alignment) + newFields.alignment = alignment + } catch (e) { + // Ignore + } + } + + if (req.query.pngalignment) { + try { + const [, , alignment] = ParseAlignment(req.query.pngalignment) + newFields.pngalignment = alignment + } catch (e) { + // Ignore + } + } + + if (Object.keys(newFields).length > 0) { + control.styleSetFields(newFields) + } + + return res.send('ok') + }) + + this.#legacyRouter.get('^/set/custom-variable/:name', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.debug(`Got HTTP /set/custom-variable/ name ${req.params.name} to value ${req.query.value}`) + const result = this.registry.instance.variable.custom.setValue(req.params.name, req.query.value) + if (result) { + return res.send(result) + } else { + return res.send('ok') + } + }) + } + + #setupNewHttpRoutes() { + // controls by location + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/press', this.#locationPress) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/down', this.#locationDown) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/up', this.#locationUp) + this.#apiRouter.post( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-left', + this.#locationRotateLeft + ) + this.#apiRouter.post( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-right', + this.#locationRotateRight + ) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/step', this.#locationStep) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style', this.#locationStyle) + + // custom variables + this.#apiRouter.post('/custom-variable/:name/value', this.#customVariableSetValue) + this.#apiRouter.get('/custom-variable/:name/value', this.#customVariableGetValue) + + // surfaces + this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan) + + // Finally, default all unhandled to 404 + this.#apiRouter.use('*', (_req, res) => { + res.status(404).send('') + }) + } + + /** + * Perform surfaces rescan + * @param {express.Request} _req + * @param {express.Response} res + * @returns {void} + */ + #surfacesRescan = (_req, res) => { + this.logger.info('Got HTTP surface rescan') + this.registry.surfaces.triggerRefreshDevices().then( + () => { + res.send('ok') + }, + () => { + res.status(500).send('fail') + } + ) + } + + /** + * Perform surfaces rescan + * @param {express.Request} req + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (req) => { + const location = { + pageNumber: Number(req.params.page), + row: Number(req.params.row), + column: Number(req.params.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Perform control press + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationPress = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control press ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + setTimeout(() => { + this.logger.info(`Auto releasing HTTP control press ${formatLocation(location)} - ${controlId}`) + + this.registry.controls.pressControl(controlId, false, 'http') + }, 20) + + res.send('ok') + } + + /** + * Perform control down + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationDown = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control down ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + res.send('ok') + } + + /** + * Perform control up + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationUp = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control up ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, false, 'http') + + res.send('ok') + } + + /** + * Perform control rotate left + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationRotateLeft = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control rotate left ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.rotateControl(controlId, false, 'http') + + res.send('ok') + } + + /** + * Perform control rotate right + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationRotateRight = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control rotate right ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.rotateControl(controlId, true, 'http') + + res.send('ok') + } + + /** + * Set control step + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationStep = (req, res) => { + const { location, controlId } = this.#locationParse(req) + const step = Number(req.query.step) + + this.logger.info(`Got HTTP control step ${formatLocation(location)} - ${controlId} to ${step}`) + if (!controlId) { + res.status(204).send('No control') + return + } + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + res.status(204).send('No control') + return + } + + if (!control.stepMakeCurrent(step)) { + res.status(400).send('Bad step') + return + } + + res.send('ok') + } + + /** + * Perform control style change + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationStyle = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control syle ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + const control = this.registry.controls.getControl(controlId) + if (!control || !control.supportsStyle) { + res.status(204).send('No control') + return + } + + const newFields = {} + + const bgcolor = req.query.bgcolor || req.body.bgcolor + if (bgcolor !== undefined) { + const newColor = parseColorToNumber(bgcolor) + if (newColor !== false) { + newFields.bgcolor = newColor + } + } + + const fgcolor = req.query.color || req.body.color + if (fgcolor !== undefined) { + const newColor = parseColorToNumber(fgcolor) + if (newColor !== false) { + newFields.color = newColor + } + } + + const size = req.query.size || req.body.size + if (size !== undefined) { + const value = size === 'auto' ? 'auto' : parseInt(size) + + if (!isNaN(Number(value)) || typeof value === 'string') { + newFields.size = value + } + } + + const text = req.query.text ?? req.body.text + if (text !== undefined) { + newFields.text = text + } + + const png64 = req.query.png64 ?? req.body.png64 + if (png64 === '') { + newFields.png64 = null + } else if (png64 && png64.match(/data:.*?image\/png/)) { + newFields.png64 = png64 + } + + const alignment = req.query.alignment || req.body.alignment + if (alignment) { + const [, , tmpAlignment] = ParseAlignment(alignment, false) + newFields.alignment = tmpAlignment + } + + const pngalignment = req.query.pngalignment || req.body.pngalignment + if (pngalignment) { + const [, , tmpAlignment] = ParseAlignment(pngalignment, false) + newFields.pngalignment = tmpAlignment + } + + if (Object.keys(newFields).length > 0) { + control.styleSetFields(newFields) + } + + // TODO - return style + res.send('ok') + } + + /** + * Perform custom variable set value + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #customVariableSetValue = (req, res) => { + const variableName = req.params.name + let variableValue = null + + if (req.query.value !== undefined) { + variableValue = req.query.value + } else if (req.body && typeof req.body !== 'object') { + variableValue = req.body.toString().trim() + } + + this.logger.debug(`Got HTTP custom variable set value name "${variableName}" to value "${variableValue}"`) + if (variableValue === null) { + res.status(400).send('No value') + return + } + + const result = this.registry.instance.variable.custom.setValue(variableName, variableValue) + if (result) { + res.status(404).send('Not found') + } else { + res.send('ok') + } + } + + /** + * Retrieve a custom variable current value + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #customVariableGetValue = (req, res) => { + const variableName = req.params.name + + this.logger.debug(`Got HTTP custom variable get value name "${variableName}"`) + + const result = this.registry.instance.variable.custom.getValue(variableName) + if (result === undefined) { + res.status(404).send('Not found') + } else { + if (typeof result === 'number') { + res.send(result + '') + } else { + res.send(result) + } + } + } +} diff --git a/lib/Service/OscApi.js b/lib/Service/OscApi.js new file mode 100644 index 0000000000..3ca2f2544c --- /dev/null +++ b/lib/Service/OscApi.js @@ -0,0 +1,420 @@ +import CoreBase from '../Core/Base.js' +import { parseColorToNumber, rgb } from '../Resources/Util.js' +import { formatLocation } from '../Shared/ControlId.js' +import RegexRouter from './RegexRouter.js' + +/** + * Class providing the OSC API. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.2.0 + * @copyright 2022 Bitfocus AS + * @license + * 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. + */ +export class ServiceOscApi extends CoreBase { + /** + * Message router + * @type {RegexRouter} + * @access private + */ + #router + + get router() { + return this.#router + } + + /** + * @param {import('../Registry.js').default} registry - the application core + */ + constructor(registry) { + super(registry, 'osc-api', 'Service/OscApi') + + this.#router = new RegexRouter() + + this.#setupLegacyOscRoutes() + this.#setupNewOscRoutes() + } + + #isLegacyRouteAllowed() { + return !!this.userconfig.getKey('osc_legacy_api_enabled') + } + + #setupLegacyOscRoutes() { + this.#router.addPath('/press/bank/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '1') { + this.logger.info(`Got /press/bank/ (press) for ${controlId}`) + this.controls.pressControl(controlId, true, undefined) + } else if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '0') { + this.logger.info(`Got /press/bank/ (release) for ${controlId}`) + this.controls.pressControl(controlId, false, undefined) + } else { + this.logger.info(`Got /press/bank/ (trigger)${controlId}`) + this.controls.pressControl(controlId, true, undefined) + + setTimeout(() => { + this.logger.info(`Auto releasing /press/bank/ (trigger)${controlId}`) + this.controls.pressControl(controlId, false, undefined) + }, 20) + } + }) + + this.#router.addPath('/style/bgcolor/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 2) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/bgcolor for ${controlId}`) + control.styleSetFields({ bgcolor: rgb(r, g, b) }) + } else { + this.logger.info(`Got /style/bgcolor for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/style/color/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 2) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/color for ${controlId}`) + control.styleSetFields({ color: rgb(r, g, b) }) + } else { + this.logger.info(`Got /style/color for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/style/text/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 0) { + const text = message.args[0].value + if (typeof text === 'string') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/text for ${controlId}`) + control.styleSetFields({ text: text }) + } else { + this.logger.info(`Got /style/color for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/rescan', (_match, _message) => { + if (!this.#isLegacyRouteAllowed()) return + + this.logger.info('Got /rescan 1') + this.surfaces.triggerRefreshDevices().catch(() => { + this.logger.debug('Scan failed') + }) + }) + } + + #setupNewOscRoutes() { + // controls by location + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/press', this.#locationPress) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/down', this.#locationDown) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/up', this.#locationUp) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-left', + this.#locationRotateLeft + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-right', + this.#locationRotateRight + ) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/step', this.#locationStep) + + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/text', + this.#locationSetStyleText + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/color', + this.#locationSetStyleColor + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/bgcolor', + this.#locationSetStyleBgcolor + ) + + // custom variables + this.#router.addPath('/custom-variable/:name/value', this.#customVariableSetValue) + + // surfaces + this.#router.addPath('/surfaces/rescan', this.#surfacesRescan) + } + + /** + * Perform surfaces rescan + * @returns {void} + */ + #surfacesRescan = () => { + this.logger.info('Got OSC surface rescan') + this.registry.surfaces.triggerRefreshDevices().catch(() => { + this.logger.debug('Scan failed') + }) + } + + /** + * Parse the location and controlId from a request + * @param {Record} match + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (match) => { + const location = { + pageNumber: Number(match.page), + row: Number(match.row), + column: Number(match.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Perform control press + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationPress = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control press ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, true, 'osc') + + setTimeout(() => { + this.logger.info(`Auto releasing OSC control press ${formatLocation(location)} - ${controlId}`) + + this.registry.controls.pressControl(controlId, false, 'osc') + }, 20) + } + + /** + * Perform control down + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationDown = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control down ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, true, 'osc') + } + + /** + * Perform control up + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationUp = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control up ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, false, 'osc') + } + + /** + * Perform control rotate left + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationRotateLeft = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control rotate left ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.rotateControl(controlId, false, 'osc') + } + + /** + * Perform control rotate right + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationRotateRight = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control rotate right ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.rotateControl(controlId, true, 'osc') + } + + /** + * Set control step + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationStep = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + const step = Number(message.args[0]?.value) + + this.logger.info(`Got OSC control step ${formatLocation(location)} - ${controlId} to ${step}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + return + } + + control.stepMakeCurrent(step) + } + + /** + * Perform control style text change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleText = (match, message) => { + if (message.args.length === 0) return + + const text = message.args[0]?.value + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set text ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + control.styleSetFields({ text: text }) + } + + /** + * Perform control style color change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleColor = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set color ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + /** @type {number | false} */ + let color = false + if (message.args.length === 3) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + color = rgb(r, g, b) + } + } else { + color = parseColorToNumber(message.args[0].value) + } + + if (color !== false) { + control.styleSetFields({ color }) + } + } + /** + * Perform control style bgcolor change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleBgcolor = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set bgcolor ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + /** @type {number | false} */ + let color = false + if (message.args.length === 3) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + color = rgb(r, g, b) + } + } else { + color = parseColorToNumber(message.args[0].value) + } + + if (color !== false) { + control.styleSetFields({ bgcolor: color }) + } + } + + /** + * Perform custom variable set value + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #customVariableSetValue = (match, message) => { + const variableName = match.name + const variableValue = message.args?.[0]?.value + + this.logger.debug(`Got HTTP custom variable set value name "${variableName}" to value "${variableValue}"`) + if (variableValue === undefined) return + + this.registry.instance.variable.custom.setValue(variableName, variableValue.toString()) + } +} diff --git a/lib/Service/OscListener.js b/lib/Service/OscListener.js index 29eee53b88..36950d8c87 100644 --- a/lib/Service/OscListener.js +++ b/lib/Service/OscListener.js @@ -1,6 +1,5 @@ -import { rgb } from '../Resources/Util.js' import ServiceOscBase from './OscBase.js' -import RegexRouter from './RegexRouter.js' +import { ServiceOscApi } from './OscApi.js' /** * Class providing OSC receive services. @@ -32,11 +31,11 @@ class ServiceOscListener extends ServiceOscBase { port = 12321 /** - * Message router - * @type {RegexRouter} + * Api router + * @type {ServiceOscApi} * @access private */ - #router + #api /** * @param {import('../Registry.js').default} registry - the application core @@ -46,118 +45,21 @@ class ServiceOscListener extends ServiceOscBase { this.init() - this.#router = new RegexRouter() - - this.#setupOscRoutes() + this.#api = new ServiceOscApi(registry) } /** * Process an incoming message from a client - * @param {import('osc').OscMessage} message - the incoming message part + * @param {import('osc').OscReceivedMessage} message - the incoming message part * @access protected */ processIncoming(message) { try { - this.#router.processMessage(message.address, message) + this.#api.router.processMessage(message.address, message) } catch (error) { this.logger.warn('OSC Error: ' + error) } } - - #setupOscRoutes() { - this.#router.addPath('/press/bank/:page(\\d+)/:bank(\\d+)', (match, message) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '1') { - this.logger.info(`Got /press/bank/ (press) for ${controlId}`) - this.controls.pressControl(controlId, true, undefined) - } else if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '0') { - this.logger.info(`Got /press/bank/ (release) for ${controlId}`) - this.controls.pressControl(controlId, false, undefined) - } else { - this.logger.info(`Got /press/bank/ (trigger)${controlId}`) - this.controls.pressControl(controlId, true, undefined) - - setTimeout(() => { - this.logger.info(`Auto releasing /press/bank/ (trigger)${controlId}`) - this.controls.pressControl(controlId, false, undefined) - }, 20) - } - }) - - this.#router.addPath('/style/bgcolor/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 2) { - const r = message.args[0].value - const g = message.args[1].value - const b = message.args[2].value - if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/bgcolor for ${controlId}`) - control.styleSetFields({ bgcolor: rgb(r, g, b) }) - } else { - this.logger.info(`Got /style/bgcolor for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/style/color/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 2) { - const r = message.args[0].value - const g = message.args[1].value - const b = message.args[2].value - if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/color for ${controlId}`) - control.styleSetFields({ color: rgb(r, g, b) }) - } else { - this.logger.info(`Got /style/color for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/style/text/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 0) { - const text = message.args[0].value - if (typeof text === 'string') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/text for ${controlId}`) - control.styleSetFields({ text: text }) - } else { - this.logger.info(`Got /style/color for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/rescan', (_match, _message) => { - this.logger.info('Got /rescan 1') - this.surfaces.triggerRefreshDevices().catch(() => { - this.logger.debug('Scan failed') - }) - }) - - this.#router.addPath('/custom-variable/:name/value', (match, message) => { - if (match.name && message.args.length > 0) { - this.logger.debug(`Setting custom-variable ${match.name} to value ${message.args[0].value}`) - this.instance.variable.custom.setValue(match.name, message.args[0].value) - } - }) - } } export default ServiceOscListener diff --git a/lib/Service/Tcp.js b/lib/Service/Tcp.js index ecf52492ee..9492b5ee17 100644 --- a/lib/Service/Tcp.js +++ b/lib/Service/Tcp.js @@ -1,5 +1,5 @@ import { decimalToRgb } from '../Resources/Util.js' -import { ApiMessageError } from './Api.js' +import { ApiMessageError, ServiceTcpUdpApi } from './TcpUdpApi.js' import ServiceTcpBase from './TcpBase.js' import { xyToOldBankIndex } from '../Shared/ControlId.js' @@ -27,7 +27,7 @@ import { xyToOldBankIndex } from '../Shared/ControlId.js' class ServiceTcp extends ServiceTcpBase { /** * The service api command processor - * @type {import('./Api.js').default} + * @type {ServiceTcpUdpApi} * @access protected * @readonly */ @@ -42,11 +42,11 @@ class ServiceTcp extends ServiceTcpBase { /** * @param {import('../Registry.js').default} registry - the application core - * @param {import('./Api.js').default} api - the handler for incoming api commands */ - constructor(registry, api) { + constructor(registry) { super(registry, 'tcp', 'Service/Tcp', 'tcp_enabled', 'tcp_listen_port') - this.#api = api + + this.#api = new ServiceTcpUdpApi(registry, 'tcp', 'tcp_legacy_api_enabled') this.graphics.on('button_drawn', (location, render) => { const bgcolor = render.style?.bgcolor || 0 diff --git a/lib/Service/TcpUdpApi.js b/lib/Service/TcpUdpApi.js new file mode 100644 index 0000000000..b7c8a9acd9 --- /dev/null +++ b/lib/Service/TcpUdpApi.js @@ -0,0 +1,549 @@ +import CoreBase from '../Core/Base.js' +import { parseColorToNumber } from '../Resources/Util.js' +import { formatLocation } from '../Shared/ControlId.js' +import RegexRouter from './RegexRouter.js' + +/** + * Common API command processing for {@link ServiceTcp} and {@link ServiceUdp}. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.3.0 + * @copyright 2022 Bitfocus AS + * @license + * 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. + */ +export class ServiceTcpUdpApi extends CoreBase { + /** + * Message router + * @type {RegexRouter} + * @access private + * @readonly + */ + #router + + /** + * Protocol name + * @type {string} + * @access private + * @readonly + */ + #protocolName + + /** + * Userconfig key to enable/disable legacy routes + * @type {string | null} + * @access private + * @readonly + */ + #legacyRoutesEnableKey + + get router() { + return this.#router + } + + /** + * @param {import('../Registry.js').default} registry - the core registry + * @param {string} protocolName - the protocol name + * @param {string | null} legacyRoutesEnableKey - Userconfig key to enable/disable legacy routes + */ + constructor(registry, protocolName, legacyRoutesEnableKey) { + super(registry, 'api', 'Service/Api') + + this.#router = new RegexRouter(() => { + throw new ApiMessageError('Syntax error') + }) + this.#protocolName = protocolName + this.#legacyRoutesEnableKey = legacyRoutesEnableKey + + this.#setupLegacyRoutes() + this.#setupNewRoutes() + } + + #checkLegacyRouteAllowed() { + if (this.#legacyRoutesEnableKey && !this.userconfig.getKey(this.#legacyRoutesEnableKey)) { + throw new ApiMessageError('Deprecated commands are disabled') + } + } + + #setupLegacyRoutes() { + this.#router.addPath('page-set :page(\\d+) :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const page = parseInt(match.page) + const surfaceId = match.surfaceId + + this.surfaces.devicePageSet(surfaceId, page) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('page-up :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const surfaceId = match.surfaceId + + this.surfaces.devicePageUp(surfaceId) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('page-down :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const surfaceId = match.surfaceId + + this.surfaces.devicePageDown(surfaceId) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('bank-press :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-press (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + + setTimeout(() => { + this.logger.info(`Auto releasing bank-press ${controlId}`) + this.controls.pressControl(controlId, false, this.#protocolName) + }, 20) + }) + + this.#router.addPath('bank-down :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-down (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('bank-up :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-up (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('bank-step :page(\\d+) :bank(\\d+) :step(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const step = parseInt(match.step) + + this.logger.info(`Got bank-step (trigger) ${controlId} ${step}`) + + if (isNaN(step) || step <= 0) throw new ApiMessageError('Step out of range') + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) throw new ApiMessageError('Invalid control') + + if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) text{ :text}?', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + const text = match.text || '' + + control.styleSetFields({ text: text }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) bgcolor #:color([a-f\\d]+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const color = parseInt(match.color, 16) + if (isNaN(color)) throw new ApiMessageError('Invalid color') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + control.styleSetFields({ bgcolor: color }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) color #:color([a-f\\d]+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const color = parseInt(match.color, 16) + if (isNaN(color)) throw new ApiMessageError('Invalid color') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + control.styleSetFields({ color: color }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('rescan', async () => { + this.#checkLegacyRouteAllowed() + + this.logger.debug('Rescanning USB') + + try { + await this.surfaces.triggerRefreshDevices() + } catch (e) { + throw new ApiMessageError('Scan failed') + } + }) + } + + #setupNewRoutes() { + // surface pages + this.#router.addPath('surface :surfaceId page-set :page(\\d+)', this.#surfaceSetPage) + this.#router.addPath('surface :surfaceId page-up', this.#surfacePageUp) + this.#router.addPath('surface :surfaceId page-down', this.#surfacePageDown) + + // control by location + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) press', this.#locationPress) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) down', this.#locationDown) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) up', this.#locationUp) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) rotate-left', this.#locationRotateLeft) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) rotate-right', this.#locationRotateRight) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) set-step :step(\\d+)', this.#locationSetStep) + + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) style text{ :text}?', this.#locationStyleText) + this.#router.addPath( + 'location :page(\\d+)/:row(\\d+)/:column(\\d+) style color :color(.+)', + this.#locationStyleColor + ) + this.#router.addPath( + 'location :page(\\d+)/:row(\\d+)/:column(\\d+) style bgcolor :bgcolor(.+)', + this.#locationStyleBgcolor + ) + + // surfaces + this.#router.addPath('surfaces rescan', this.#surfacesRescan) + + // custom variables + this.#router.addPath('custom-variable :name set-value :value(.*)', this.#customVariableSetValue) + } + + /** + * Perform surface set to page + * @param {Record} match + * @returns {string | void} + */ + #surfaceSetPage = (match) => { + const page = parseInt(match.page) + const surfaceId = match.surfaceId + + this.surfaces.devicePageSet(surfaceId, page) + + return `If ${surfaceId} is connected` + } + + /** + * Perform surface page up + * @param {Record} match + * @returns {string | void} + */ + #surfacePageUp = (match) => { + const surfaceId = match.surfaceId + + this.surfaces.devicePageUp(surfaceId) + + return `If ${surfaceId} is connected` + } + + /** + * Perform surface page down + * @param {Record} match + * @returns {string | void} + */ + #surfacePageDown = (match) => { + const surfaceId = match.surfaceId + + this.surfaces.devicePageDown(surfaceId) + + return `If ${surfaceId} is connected` + } + + /** + * Perform control press + * @param {Record} match + * @returns {void} + */ + #locationPress = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location press at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + + setTimeout(() => { + this.logger.info(`Auto releasing ${formatLocation(location)} (${controlId})`) + this.controls.pressControl(controlId, false, this.#protocolName) + }, 20) + } + + /** + * Perform control down + * @param {Record} match + * @returns {void} + */ + #locationDown = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location down at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control up + * @param {Record} match + * @returns {void} + */ + #locationUp = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location up at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control rotate left + * @param {Record} match + * @returns {void} + */ + #locationRotateLeft = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location rotate-left at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.rotateControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control rotate right + * @param {Record} match + * @returns {void} + */ + #locationRotateRight = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location rotate-right at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.rotateControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Set control step + * @param {Record} match + * @returns {void} + */ + #locationSetStep = (match) => { + const step = parseInt(match.step) + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location set-step at ${formatLocation(location)} (${controlId}) to ${step}`) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + throw new ApiMessageError('No control at location') + } + + if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + } + + /** + * Perform control style text change + * @param {Record} match + * @returns {void} + */ + #locationStyleText = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style text at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const text = match.text || '' + + control.styleSetFields({ text: text }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control style color change + * @param {Record} match + * @returns {void} + */ + #locationStyleColor = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style color at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const color = parseColorToNumber(match.color) + + control.styleSetFields({ color: color }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control style bgcolor change + * @param {Record} match + * @returns {void} + */ + #locationStyleBgcolor = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style bgcolor at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const color = parseColorToNumber(match.bgcolor) + + control.styleSetFields({ bgcolor: color }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform surfaces rescan + * @param {Record} _match + * @returns {Promise} + */ + #surfacesRescan = async (_match) => { + this.logger.debug('Rescanning USB') + + try { + await this.surfaces.triggerRefreshDevices() + } catch (e) { + throw new ApiMessageError('Scan failed') + } + } + + /** + * Perform custom variable set value + * @param {Record} match + * @returns {void} + */ + #customVariableSetValue = (match) => { + const result = this.instance.variable.custom.setValue(match.name, match.value) + if (result) { + throw new ApiMessageError(result) + } + } + + /** + * Parse the location and controlId from a request + * @param {Record} match + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (match) => { + const location = { + pageNumber: Number(match.page), + row: Number(match.row), + column: Number(match.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Fire an API command from a raw TCP/UDP command + * @param {string} data - the raw command + * @returns {Promise} + */ + async parseApiCommand(data) { + data = data.trim() + this.logger.silly(`API parsing command: ${data}`) + + return this.#router.processMessage(data) + } +} + +export class ApiMessageError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message) + } +} diff --git a/lib/Service/Udp.js b/lib/Service/Udp.js index 525270a265..b06964a538 100644 --- a/lib/Service/Udp.js +++ b/lib/Service/Udp.js @@ -1,4 +1,4 @@ -import ServiceApi from './Api.js' +import { ServiceTcpUdpApi } from './TcpUdpApi.js' import ServiceUdpBase from './UdpBase.js' /** @@ -25,7 +25,7 @@ import ServiceUdpBase from './UdpBase.js' class ServiceUdp extends ServiceUdpBase { /** * The service api command processor - * @type {ServiceApi} + * @type {ServiceTcpUdpApi} * @access protected * @readonly */ @@ -40,11 +40,11 @@ class ServiceUdp extends ServiceUdpBase { /** * @param {import('../Registry.js').default} registry - the application core - * @param {import('./Api.js').default} api - the handler for incoming api commands */ - constructor(registry, api) { + constructor(registry) { super(registry, 'udp', 'Service/Udp', 'udp_enabled', 'udp_listen_port') - this.#api = api + + this.#api = new ServiceTcpUdpApi(registry, 'udp', 'udp_legacy_api_enabled') this.init() } diff --git a/lib/UI/Express.js b/lib/UI/Express.js index 0df55e8e6d..bc285f7a28 100644 --- a/lib/UI/Express.js +++ b/lib/UI/Express.js @@ -17,7 +17,7 @@ import Express from 'express' import path from 'path' -import { isPackaged, ParseAlignment, rgb } from '../Resources/Util.js' +import { isPackaged } from '../Resources/Util.js' import cors from 'cors' import fs from 'fs' // @ts-ignore @@ -71,6 +71,8 @@ class UIExpress { constructor(registry) { this.registry = registry + this.legacyApiRouter = Express.Router() + this.app.use(cors()) this.app.use((_req, res, next) => { @@ -103,190 +105,7 @@ class UIExpress { } }) - this.app.options('/press/bank/*', (_req, res, _next) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - res.send(200) - }) - - this.app.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info(`Got HTTP /press/bank/ (trigger) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, true, undefined) - - setTimeout(() => { - this.logger.info(`Auto releasing HTTP /press/bank/ page ${req.params.page} button ${req.params.bank}`) - this.registry.controls.pressControl(controlId, false, undefined) - }, 20) - - res.send('ok') - }) - - this.app.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})/:direction(down|up)', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - if (req.params.direction == 'down') { - this.logger.info(`Got HTTP /press/bank/ (DOWN) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex( - Number(req.params.page), - Number(req.params.bank) - ) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, true, undefined) - } else { - this.logger.info(`Got HTTP /press/bank/ (UP) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex( - Number(req.params.page), - Number(req.params.bank) - ) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, false, undefined) - } - - res.send('ok') - }) - - this.app.get('^/rescan', (_req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info('Got HTTP /rescan') - this.registry.surfaces.triggerRefreshDevices().then( - () => { - res.send('ok') - }, - () => { - res.send('fail') - } - ) - }) - - this.app.get('^/style/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info(`Got HTTP /style/bank ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - const control = this.registry.controls.getControl(controlId) - - if (!control || !control.supportsStyle) { - res.status(404) - res.send('Not found') - return - } - - const newFields = {} - - if (req.query.bgcolor) { - const value = req.query.bgcolor.replace(/#/, '') - const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) - if (color !== false) { - newFields.bgcolor = color - } - } - - if (req.query.color) { - const value = req.query.color.replace(/#/, '') - const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) - if (color !== false) { - newFields.color = color - } - } - - if (req.query.size) { - const value = req.query.size.replace(/pt/i, '') - newFields.size = value - } - - if (req.query.text || req.query.text === '') { - newFields.text = req.query.text - } - - if (req.query.png64 || req.query.png64 === '') { - if (req.query.png64 === '') { - newFields.png64 = null - } else if (!req.query.png64.match(/data:.*?image\/png/)) { - res.status(400) - res.send('png64 must be a base64 encoded png file') - return - } else { - newFields.png64 = req.query.png64 - } - } - - if (req.query.alignment) { - try { - const [, , alignment] = ParseAlignment(req.query.alignment) - newFields.alignment = alignment - } catch (e) { - // Ignore - } - } - - if (req.query.pngalignment) { - try { - const [, , alignment] = ParseAlignment(req.query.pngalignment) - newFields.pngalignment = alignment - } catch (e) { - // Ignore - } - } - - if (Object.keys(newFields).length > 0) { - control.styleSetFields(newFields) - } - - res.send('ok') - }) - - this.app.get('^/set/custom-variable/:name', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.debug(`Got HTTP /set/custom-variable/ name ${req.params.name} to value ${req.query.value}`) - const result = this.registry.instance.variable.custom.setValue(req.params.name, req.query.value) - if (result) { - res.send(result) - } else { - res.send('ok') - } - }) + this.app.use(this.legacyApiRouter) /** * We don't want to ship hundreds of loose files, so instead we can serve the webui files from a zip file diff --git a/package.json b/package.json index 9b2e7beb2f..d0db3fe36c 100755 --- a/package.json +++ b/package.json @@ -48,15 +48,18 @@ "@types/pngjs": "^6.0.2", "@types/semver": "^7.5.3", "@types/socketcluster-client": "^16.0.1", + "@types/supertest": "^2.0.15", "@types/uuid": "^9.0.5", "@types/workerpool": "^6.4.4", "@types/ws": "^8.5.7", "chokidar": "^3.5.3", "dotenv": "^16.3.1", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.5", "jsdoc": "^4.0.2", "octokit": "^3.1.1", "prettier": "^3.0.3", + "supertest": "^6.3.3", "tar": "^6.2.0", "typescript": "^5.2.2", "webpack": "^5.89.0", diff --git a/test/Service/HttpApi.test.js b/test/Service/HttpApi.test.js new file mode 100644 index 0000000000..911dccc6ae --- /dev/null +++ b/test/Service/HttpApi.test.js @@ -0,0 +1,1018 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ServiceHttpApi } from '../../lib/Service/HttpApi' +import express from 'express' +import supertest from 'supertest' +import bodyParser from 'body-parser' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('HttpApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + userconfig: { + // Force config to return true + getKey: () => true, + }, + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const legacyRouter = express.Router() + const service = new ServiceHttpApi(registry, legacyRouter) + + const app = express() + + app.use(bodyParser.text()) + app.use(bodyParser.json()) + + app.use(legacyRouter) + service.bindToApp(app) + + return { + app, + registry, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { app, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + const res = await supertest(app).post('/api/surfaces/rescan').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + }) + + test('failed', async () => { + const { app, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + const res = await supertest(app).post('/api/surfaces/rescan').send() + expect(res.status).toBe(500) + expect(res.text).toBe('fail') + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('no value', async () => { + const { app } = createService() + + // Perform the request + const res = await supertest(app).post('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(400) + expect(res.text).toBe('No value') + }) + + test('ok from query', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + const res = await supertest(app).post('/api/custom-variable/my-var-name/value?value=123').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok from body', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + const res = await supertest(app) + .post('/api/custom-variable/my-var-name/value') + .set('Content-Type', 'text/plain') + .send('def') + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + + test('unknown name', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue('Unknown name') + + // Perform the request + const res = await supertest(app) + .post('/api/custom-variable/my-var-name/value') + .set('Content-Type', 'text/plain') + .send('def') + expect(res.status).toBe(404) + expect(res.text).toBe('Not found') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + }) + + describe('get value', () => { + test('no value', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(undefined) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(404) + expect(res.text).toBe('Not found') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value empty string', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue('') + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value proper string', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue('something 123') + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('something 123') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value zero number', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(0) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('0') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value real number', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(455.8) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('455.8') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value false', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(false) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('false') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value true', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('true') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value object', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue({ + a: 1, + b: 'str', + }) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('{"a":1,"b":"str"}') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/down').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/down').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/up').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/up').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/press').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/press').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'http') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-left').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-left').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-right').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-right').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step?step=2') + expect(res.status).toBe(204) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step') + expect(res.status).toBe(400) + expect(res.text).toBe('Bad step') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('test') + + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(NaN) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step?step=2') + expect(res.status).toBe(200) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/style').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('control without style', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + registry.controls.getControl.mockReturnValue({ abc: null }) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/style').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + async function testSetStyle(queryStr, body, expected) { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app) + .post(`/api/location/1/2/3/style?${queryStr}`) + .set('Content-Type', 'application/json') + .send(body) + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + if (expected) { + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith(expected) + } else { + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(0) + } + } + + test('set style without properties', async () => { + await testSetStyle('', undefined, null) + }) + + test('set style unknown properties', async () => { + await testSetStyle('abc=123', { def: 456 }, null) + }) + + test('set color properties', async () => { + await testSetStyle( + 'bgcolor=%23abcdef', + { color: 'rgb(1,2,3)' }, + { + bgcolor: rgb('ab', 'cd', 'ef', 16), + color: rgb(1, 2, 3), + } + ) + }) + + test('set color properties bad', async () => { + await testSetStyle('bgcolor=bad', { color: 'rgb(1,2,an)' }, null) + }) + + test('set text size auto', async () => { + await testSetStyle('', { size: 'auto' }, { size: 'auto' }) + }) + + test('set text size bad', async () => { + await testSetStyle('', { size: 'bad' }, null) + }) + + test('set text size number', async () => { + await testSetStyle('size=134.2', {}, { size: 134 }) + }) + + test('set text', async () => { + await testSetStyle('text=something%20%23%20new', {}, { text: 'something # new' }) + }) + + test('set empty text', async () => { + await testSetStyle('text=', {}, { text: '' }) + await testSetStyle('', { text: '' }, { text: '' }) + }) + + test('set empty png', async () => { + await testSetStyle('png64=', {}, { png64: null }) + await testSetStyle('', { png64: '' }, { png64: null }) + }) + + test('set bad png', async () => { + await testSetStyle('', { png64: 'something' }, null) + }) + + test('set png', async () => { + await testSetStyle('', { png64: 'data:image/png;base64,aaabncc' }, { png64: 'data:image/png;base64,aaabncc' }) + }) + + test('set bad alignment', async () => { + await testSetStyle('', { alignment: 'something' }, { alignment: 'center:center' }) + await testSetStyle('', { alignment: 'top:nope' }, { alignment: 'center:center' }) + }) + + test('set alignment', async () => { + await testSetStyle('', { alignment: 'left:top' }, { alignment: 'left:top' }) + }) + + test('set bad pngalignment', async () => { + await testSetStyle('', { pngalignment: 'something' }, { pngalignment: 'center:center' }) + await testSetStyle('', { pngalignment: 'top:nope' }, { pngalignment: 'center:center' }) + }) + + test('set pngalignment', async () => { + await testSetStyle('', { pngalignment: 'left:top' }, { pngalignment: 'left:top' }) + }) + }) + }) +}) diff --git a/test/Service/OscApi.test.js b/test/Service/OscApi.test.js new file mode 100644 index 0000000000..bdeac3cb14 --- /dev/null +++ b/test/Service/OscApi.test.js @@ -0,0 +1,815 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ServiceOscApi } from '../../lib/Service/OscApi' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('OscApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const service = new ServiceOscApi(registry) + const router = service.router + + return { + registry, + router, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + router.processMessage('/surfaces/rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + + test('failed', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + router.processMessage('/surfaces/rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('no value', async () => { + const { router } = createService() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { args: [] }) + }) + + test('ok from query', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: '123', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok from body', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: 'def', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + + test('unknown name', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue('Unknown name') + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: 'def', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'osc') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('string step', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: '4' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(4) + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1a/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2a/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3a/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style: text', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/text', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/text', { args: [{ value: 'def' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: 'def' }) + }) + }) + + describe('set style: color', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/color', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(args, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/color', { args }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ color: expected }) + } + + test('ok hex', async () => { + await runColor([{ value: '#abcdef' }], rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok separate', async () => { + await runColor([{ value: 5 }, { value: 8 }, { value: 11 }], rgb(5, 8, 11)) + }) + + test('ok css', async () => { + await runColor([{ value: 'rgb(1,4,5)' }], rgb(1, 4, 5)) + }) + }) + + describe('set style: bgcolor', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/bgcolor', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(args, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/bgcolor', { args }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ bgcolor: expected }) + } + + test('ok hex', async () => { + await runColor([{ value: '#abcdef' }], rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok separate', async () => { + await runColor([{ value: 5 }, { value: 8 }, { value: 11 }], rgb(5, 8, 11)) + }) + + test('ok css', async () => { + await runColor([{ value: 'rgb(1,4,5)' }], rgb(1, 4, 5)) + }) + }) + }) +}) diff --git a/test/Service/Rosstalk.test.js b/test/Service/Rosstalk.test.js new file mode 100644 index 0000000000..6aeb6c69fb --- /dev/null +++ b/test/Service/Rosstalk.test.js @@ -0,0 +1,109 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import ServiceRosstalk from '../../lib/Service/Rosstalk' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('Rosstalk', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + page: mock( + { + getControlIdAt: jest.fn(), + }, + mockOptions + ), + controls: mock( + { + pressControl: jest.fn(), + }, + mockOptions + ), + userconfig: { + // Force config to return true + getKey: () => false, + }, + }, + mockOptions + ) + + const service = new ServiceRosstalk(registry) + + return { + registry, + service, + logger, + } + } + + describe('CC - bank', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { registry, service } = createService() + + service.processIncoming(null, 'CC 12:24') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenLastCalledWith({ + pageNumber: 12, + row: 2, + column: 7, + }) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('out of range', async () => { + const { registry, service } = createService() + + service.processIncoming(null, 'CC 12:34') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { registry, service } = createService() + registry.page.getControlIdAt.mockReturnValue('myControl') + + service.processIncoming(null, 'CC 12:24') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenLastCalledWith({ + pageNumber: 12, + row: 2, + column: 7, + }) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('myControl', true, 'rosstalk') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('myControl', false, 'rosstalk') + }) + }) +}) diff --git a/test/Service/TcpUdpApi.test.js b/test/Service/TcpUdpApi.test.js new file mode 100644 index 0000000000..e8b1ff4bfa --- /dev/null +++ b/test/Service/TcpUdpApi.test.js @@ -0,0 +1,790 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ApiMessageError, ServiceTcpUdpApi } from '../../lib/Service/TcpUdpApi' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('TcpUdpApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const service = new ServiceTcpUdpApi(registry, 'fake-proto', null) + const router = service.router + + return { + registry, + router, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + await router.processMessage('surfaces rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + + test('failed', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + await expect(router.processMessage('surfaces rescan')).rejects.toEqual(new ApiMessageError('Scan failed')) + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('ok from query', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + await router.processMessage('custom-variable my-var-name set-value 123') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok empty', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + await router.processMessage('custom-variable my-var-name set-value ') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 down')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 up')).toThrow(new ApiMessageError('No control at location')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 press')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 rotate-left')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 rotate-right')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 set-step 2')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 step')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 set-step 2') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style: text', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style text abc')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('location 1/2/3 style text def') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: 'def' }) + }) + + test('ok no text', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('location 1/2/3 style text') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: '' }) + }) + }) + + describe('set style: color', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style color abc')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(input, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage(`location 1/2/3 style color ${input}`) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ color: expected }) + } + + test('ok hex', async () => { + await runColor('#abcdef', rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok css', async () => { + await runColor('rgb(1,4,5)', rgb(1, 4, 5)) + }) + }) + + describe('set style: bgcolor', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style bgcolor abc')).toThrow( + new ApiMessageError('No control at location') + ) + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(input, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage(`location 1/2/3 style bgcolor ${input}`) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ bgcolor: expected }) + } + + test('ok hex', async () => { + await runColor('#abcdef', rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok css', async () => { + await runColor('rgb(1,4,5)', rgb(1, 4, 5)) + }) + }) + }) +}) diff --git a/tools/dev.mjs b/tools/dev.mjs index 40367051f2..5bbba6c353 100644 --- a/tools/dev.mjs +++ b/tools/dev.mjs @@ -14,7 +14,7 @@ const cachedDebounces = {} chokidar .watch(['**/*.mjs', '**/*.js', '**/*.cjs', '**/*.json'], { ignoreInitial: true, - ignored: ['**/node_modules/**', './webui/', './launcher/', './dist/'], + ignored: ['**/node_modules/**', './webui/', './launcher/', './dist/', './test/'], }) .on('all', (event, filename) => { const fullpath = path.resolve(filename) diff --git a/webui/src/UserConfig/HttpConfig.jsx b/webui/src/UserConfig/HttpConfig.jsx new file mode 100644 index 0000000000..2de10c930f --- /dev/null +++ b/webui/src/UserConfig/HttpConfig.jsx @@ -0,0 +1,57 @@ +import React from 'react' +import { CButton, CInput } from '@coreui/react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faUndo } from '@fortawesome/free-solid-svg-icons' +import CSwitch from '../CSwitch' + +export function HttpConfig({ config, setValue, resetValue }) { + return ( + <> +
+ HTTP +
HTTP API +
+ setValue('http_api_enabled', e.currentTarget.checked)} + /> +
+
+ resetValue('http_api_enabled')} title="Reset to default"> + + +
+ Deprecated HTTP API +
+ (This portion of the API will be removed in a future release) +
+
+ setValue('http_legacy_api_enabled', e.currentTarget.checked)} + /> +
+
+ resetValue('http_legacy_api_enabled')} title="Reset to default"> + + +
+ Deprecated OSC API +
+ (This portion of the API will be removed in a future release) +
+
+ setValue('osc_legacy_api_enabled', e.currentTarget.checked)} + /> +
+
+ resetValue('osc_legacy_api_enabled')} title="Reset to default"> + + +
+ Deprecated TCP API +
+ (This portion of the API will be removed in a future release) +
+
+ setValue('tcp_legacy_api_enabled', e.currentTarget.checked)} + /> +
+
+ resetValue('tcp_legacy_api_enabled')} title="Reset to default"> + + +
+ Deprecated UDP API +
+ (This portion of the API will be removed in a future release) +
+
+ setValue('udp_legacy_api_enabled', e.currentTarget.checked)} + /> +
+
+ resetValue('udp_legacy_api_enabled')} title="Reset to default"> + + +
+
@@ -169,48 +154,34 @@ export const SurfacesPage = memo(function SurfacesPage() { - {surfacesList.map((surface) => ( - - ))} - - {surfacesList.length === 0 && ( - - - + {surfacesContext.map((group) => + group.isAutoGroup && (group.surfaces || []).length === 1 ? ( + + ) : ( + + ) )} - -
NO
No control surfaces have been detected
-
Disconnected
- - - - - - - - - - - - {offlineSurfacesList.map((surface) => ( - - ))} - - {offlineSurfacesList.length === 0 && ( + {surfacesContext.length === 0 && ( - + )} @@ -219,57 +190,98 @@ export const SurfacesPage = memo(function SurfacesPage() { ) }) -function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmulator }) { - const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) - const configureSurface2 = useCallback(() => configureSurface(surface), [configureSurface, surface]) - const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) +function ManualGroupRow({ + group, + configureGroup, + deleteGroup, + updateName, + configureSurface, + deleteEmulator, + forgetSurface, +}) { + const configureGroup2 = useCallback(() => configureGroup(group.id), [configureGroup, group.id]) + const deleteGroup2 = useCallback(() => deleteGroup(group.id), [deleteGroup, group.id]) + const updateName2 = useCallback((val) => updateName(group.id, val), [updateName, group.id]) return ( - - - - - - - - + <> + + + + + + + + + {(group.surfaces || []).map((surface) => ( + + ))} + ) } -function OfflineSuraceRow({ surface, updateName, forgetSurface }) { +function SurfaceRow({ surface, index, updateName, configureSurface, deleteEmulator, forgetSurface, noBorder }) { const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) + const configureSurface2 = useCallback(() => configureSurface(surface.id), [configureSurface, surface.id]) + const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) return ( - + + + ) diff --git a/webui/src/scss/_common.scss b/webui/src/scss/_common.scss index 6eb683d0cc..aab0651486 100644 --- a/webui/src/scss/_common.scss +++ b/webui/src/scss/_common.scss @@ -102,6 +102,10 @@ code { } .table { color: #111; + + &.table-margin-top { + margin-top: 1rem; + } } .modal { @@ -147,3 +151,7 @@ code { .c-switch-input { display: none; } + +.noBorder td { + border: none; +} From f56e27ffe4a4d7cf34bf583204ec6dbeb6304bc7 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 25 Nov 2023 23:05:41 +0000 Subject: [PATCH 47/53] fix: internal feedbacks with 'location' field not being fixed up from 3.1 correctly --- lib/Internal/Controls.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/Internal/Controls.js b/lib/Internal/Controls.js index 4034728d88..9a0f2ed595 100644 --- a/lib/Internal/Controls.js +++ b/lib/Internal/Controls.js @@ -69,6 +69,7 @@ const CHOICES_DYNAMIC_LOCATION = [ { type: 'textinput', label: 'Location (text with variables)', + tooltip: 'eg 1/0/0 or $(this:page)/$(this:row)/$(this:column)', id: 'location_text', default: '$(this:page)/$(this:row)/$(this:column)', isVisible: (options) => options.location_target === 'text', @@ -79,6 +80,7 @@ const CHOICES_DYNAMIC_LOCATION = [ { type: 'textinput', label: 'Location (expression)', + tooltip: 'eg `1/0/0` or `${$(this:page) + 1}/${$(this:row)}/${$(this:column)}`', id: 'location_expression', default: `concat($(this:page), '/', $(this:row), '/', $(this:column))`, isVisible: (options) => options.location_target === 'expression', @@ -608,17 +610,25 @@ export default class Controls { let changed = false if (feedback.options.bank !== undefined) { - const xy = oldBankIndexToXY(feedback.options.bank) - if (feedback.options.bank == 0) { + if (feedback.options.bank == 0 && feedback.options.page == 0) { feedback.options.location_target = 'this' delete feedback.options.bank + delete feedback.options.page changed = true - } else if (xy) { + } else { + const xy = oldBankIndexToXY(feedback.options.bank) + + let pageNumber = feedback.options.page + if (pageNumber == 0) pageNumber = `$(this:page)` + + const buttonId = xy ? `${xy[1]}/${xy[0]}` : `$(this:row)/$(this:column)` + feedback.options.location_target = 'text' - feedback.options.location_text = `${xy[1]}/${xy[0]}` + feedback.options.location_text = `${pageNumber}/${buttonId}` delete feedback.options.bank + delete feedback.options.page changed = true } } From 97af7edf533f0b2f3c7d098edb3e57ff3f549844 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sat, 25 Nov 2023 23:08:57 +0000 Subject: [PATCH 48/53] fix: track grid size of surfaces in their config (for future use) --- lib/Surface/Handler.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Surface/Handler.js b/lib/Surface/Handler.js index 77cdab3557..956d49f4de 100644 --- a/lib/Surface/Handler.js +++ b/lib/Surface/Handler.js @@ -221,6 +221,7 @@ class SurfaceHandler extends CoreBase { // Persist the type in the db for use when it is disconnected this.#surfaceConfig.type = this.panel.info.type || 'Unknown' this.#surfaceConfig.integrationType = integrationType + this.#surfaceConfig.gridSize = this.panel.gridSize if (!this.#surfaceConfig.config) { this.#surfaceConfig.config = cloneDeep(SurfaceHandler.PanelDefaults) @@ -448,6 +449,9 @@ class SurfaceHandler extends CoreBase { #onDeviceResized() { if (!this.panel) return + this.#surfaceConfig.gridSize = this.panel.gridSize + this.#saveConfig() + this.#drawPage() } From 3b0356bef6d4caca04daa47a751c06e27e8eabb3 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 26 Nov 2023 11:40:15 +0000 Subject: [PATCH 49/53] fix: preserve sortOrder when importing instances #2658 --- lib/Data/ImportExport.js | 10 ++++++++-- lib/Data/Model/ExportModel.ts | 3 ++- lib/Instance/Controller.js | 2 -- lib/Shared/Import.js | 17 +++++++++++++++++ webui/src/ImportExport/Import/Page.jsx | 13 +++++++++++-- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 lib/Shared/Import.js diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index a58070566d..07ed68d06d 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -30,6 +30,7 @@ import fs from 'fs' import zlib from 'node:zlib' import { stringify as csvStringify } from 'csv-stringify/sync' import { visitEventOptions } from '../Resources/EventDefinitions.js' +import { compareExportedInstances } from '../Shared/Import.js' /** * Default buttons on fresh pages @@ -622,6 +623,7 @@ class DataImportExport extends CoreBase { clientObject.instances[instanceId] = { instance_type: this.instance.modules.verifyInstanceTypeIsCurrent(instance.instance_type), label: instance.label, + sortOrder: instance.sortOrder, } } @@ -1038,7 +1040,11 @@ class DataImportExport extends CoreBase { const instanceIdMap = {} if (instances) { - for (const [oldId, obj] of Object.entries(instances)) { + const instanceEntries = Object.entries(instances) + .filter((ent) => !!ent[1]) + .sort(compareExportedInstances) + + for (const [oldId, obj] of instanceEntries) { if (!obj) continue const remapId = instanceRemapping[oldId] @@ -1346,7 +1352,7 @@ export default DataImportExport * * @typedef {{ * type: 'page' | 'full' | 'trigger_list' - * instances: Record + * instances: Record * controls: boolean * customVariables: boolean * surfaces: boolean diff --git a/lib/Data/Model/ExportModel.ts b/lib/Data/Model/ExportModel.ts index d53ddaf656..e7271e13ad 100644 --- a/lib/Data/Model/ExportModel.ts +++ b/lib/Data/Model/ExportModel.ts @@ -50,13 +50,14 @@ export type ExportInstanceFullv4 = { lastUpgradeIndex: number instance_type: string enabled: boolean - sortOrder: number + sortOrder?: number } export type ExportInstanceMinimalv4 = { label: string instance_type: string lastUpgradeIndex: number + sortOrder?: number } export interface ExportGridSize { diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index 4f85fd7efe..dd02396b9b 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -440,8 +440,6 @@ class Instance extends CoreBase { : { ...rawObj, } - // @ts-ignore types - delete obj.sortOrder return clone ? cloneDeep(obj) : obj } diff --git a/lib/Shared/Import.js b/lib/Shared/Import.js new file mode 100644 index 0000000000..bee57ff05f --- /dev/null +++ b/lib/Shared/Import.js @@ -0,0 +1,17 @@ +/** + * + * @param {[id: string, obj: import("../Data/Model/ExportModel.js").ExportInstanceFullv4 | import("../Data/Model/ExportModel.js").ExportInstanceMinimalv4 | undefined]} param0 + * @param {[id: string, obj: import("../Data/Model/ExportModel.js").ExportInstanceFullv4 | import("../Data/Model/ExportModel.js").ExportInstanceMinimalv4 | undefined]} param1 + * @returns number + */ +export function compareExportedInstances([aId, aObj], [bId, bObj]) { + if (!aObj || !bObj) return 0 // Satisfy typings + + // If order is the same, sort by label + if (bObj.sortOrder === aObj.sortOrder) { + return (aObj.label ?? aId).localeCompare(bObj.label ?? bId) + } + + // sort by order + return (aObj.sortOrder ?? Number.POSITIVE_INFINITY) - (bObj.sortOrder ?? Number.POSITIVE_INFINITY) +} diff --git a/webui/src/ImportExport/Import/Page.jsx b/webui/src/ImportExport/Import/Page.jsx index 81402f5d99..37f2e4ecfb 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.jsx @@ -15,6 +15,7 @@ import { ButtonGridIcon, ButtonGridIconBase, ButtonInfiniteGrid } from '../../Bu import { faHome } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useHasBeenRendered } from '../../Hooks/useHasBeenRendered' +import { compareExportedInstances } from '@companion/shared/Import' export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, doImport }) { const pages = useContext(PagesContext) @@ -153,6 +154,14 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) + const sortedInstances = useMemo(() => { + if (!snapshot.instances) return [] + + return Object.entries(snapshot.instances) + .filter((ent) => !!ent[1]) + .sort(compareExportedInstances) + }, [snapshot.instances]) + return (
Link import connections with existing connections
@@ -166,12 +175,12 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) {
- {Object.keys(snapshot.instances || {}).length === 0 && ( + {sortedInstances.length === 0 && ( )} - {Object.entries(snapshot.instances || {}).map(([key, instance]) => { + {sortedInstances.map(([key, instance]) => { const snapshotModule = modules[instance.instance_type] const currentInstances = Object.entries(connectionsContext).filter( ([id, inst]) => inst.instance_type === instance.instance_type From 9963b8b3cd8cb4d71ee32bd8e385bf6be79aab0b Mon Sep 17 00:00:00 2001 From: Companion Module Sync Date: Sun, 26 Nov 2023 12:32:02 +0000 Subject: [PATCH 50/53] chore: update bundled-modules c934bf8 update shure-scm820 to v2.0.3 a770337 update modulopi-moduloplayer to 2.0.4 --- bundled-modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-modules b/bundled-modules index f92d53b7d6..c934bf828e 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit f92d53b7d60da2ad051ca3194d29bca09c445add +Subproject commit c934bf828ea87c4620b44b750af17f026e41f078 From 22a61fdaa0bfe3a87ae53cfd64ad19ce9dc2bfd8 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 26 Nov 2023 12:34:36 +0000 Subject: [PATCH 51/53] chore: update presets wording --- webui/src/Buttons/Presets.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/webui/src/Buttons/Presets.jsx b/webui/src/Buttons/Presets.jsx index 0ad82b840a..917efbedfc 100644 --- a/webui/src/Buttons/Presets.jsx +++ b/webui/src/Buttons/Presets.jsx @@ -124,9 +124,11 @@ function PresetsConnectionList({ presets, setConnectionAndCategory }) {
Presets

- Some connections support something we call presets, it's ready made buttons with text, actions and feedback so - you don't need to spend time making everything from scratch. They can be drag and dropped onto your button - layout. + Here are some ready made buttons with text, actions and feedback which you can drop onto a button to help you + get started quickly. +
+ Not every module provides presets, and you can do a lot more by editing the actions and feedbacks on a button + manually.

{options.length === 0 ? ( You have no connections that support presets at the moment. From 2e47ca3bffcd3e6e42a748eb1abb986bf4d415ec Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 26 Nov 2023 14:11:53 +0000 Subject: [PATCH 52/53] fix: emulators list broken by #2655 --- lib/Surface/Controller.js | 9 ++++-- webui/src/Controls/InternalInstanceFields.jsx | 4 +-- webui/src/Emulator/List.jsx | 28 +++++++++++++------ webui/src/Surfaces/EditModal.jsx | 14 ++++++---- webui/src/Surfaces/index.jsx | 6 ++-- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index 1dc7393621..3264063953 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -50,7 +50,7 @@ const SurfacesRoom = 'surfaces' class SurfaceController extends CoreBase { /** * The last sent json object - * @type {ClientDevicesListItem[] | null} + * @type {Record | null} * @access private */ #lastSentJson = null @@ -846,7 +846,12 @@ class SurfaceController extends CoreBase { } updateDevicesList() { - const newJson = cloneDeep(this.getDevicesList()) + const newJsonArr = cloneDeep(this.getDevicesList()) + /** @type {Record} */ + const newJson = {} + for (const surface of newJsonArr) { + newJson[surface.id] = surface + } if (this.io.countRoomMembers(SurfacesRoom) > 0) { const patch = jsonPatch.compare(this.#lastSentJson || {}, newJson || {}) diff --git a/webui/src/Controls/InternalInstanceFields.jsx b/webui/src/Controls/InternalInstanceFields.jsx index 7b7080dad9..b90d962423 100644 --- a/webui/src/Controls/InternalInstanceFields.jsx +++ b/webui/src/Controls/InternalInstanceFields.jsx @@ -192,14 +192,14 @@ function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disable } if (!useRawSurfaces) { - for (const group of surfacesContext ?? []) { + for (const group of Object.values(surfacesContext ?? {})) { choices.push({ label: group.displayName, id: group.id, }) } } else { - for (const group of surfacesContext ?? []) { + for (const group of Object.values(surfacesContext ?? {})) { for (const surface of group.surfaces) { choices.push({ label: surface.displayName, diff --git a/webui/src/Emulator/List.jsx b/webui/src/Emulator/List.jsx index 73e78a1990..c6295c74d7 100644 --- a/webui/src/Emulator/List.jsx +++ b/webui/src/Emulator/List.jsx @@ -9,19 +9,19 @@ import { cloneDeep } from 'lodash-es' export function EmulatorList() { const socket = useContext(SocketContext) - const [surfaces, setSurfaces] = useState(null) + const [surfaceGroups, setSurfaceGroups] = useState(null) const [loadError, setLoadError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doRetryLoad = useCallback(() => setReloadToken(nanoid()), []) useEffect(() => { - setSurfaces(null) + setSurfaceGroups(null) setLoadError(null) socketEmitPromise(socket, 'surfaces:subscribe', []) .then((surfaces) => { - setSurfaces(surfaces) + setSurfaceGroups(surfaces) }) .catch((e) => { console.error('Failed to load surfaces', e) @@ -29,7 +29,7 @@ export function EmulatorList() { }) const patchSurfaces = (patch) => { - setSurfaces((oldSurfaces) => { + setSurfaceGroups((oldSurfaces) => { return jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument }) } @@ -43,14 +43,26 @@ export function EmulatorList() { }, [socket, reloadToken]) const emulators = useMemo(() => { - return Object.values(surfaces?.available || {}).filter((s) => s.id.startsWith('emulator:')) - }, [surfaces]) + const emulators = [] + + for (const group of Object.values(surfaceGroups ?? {})) { + if (!group) continue + + for (const surface of group.surfaces) { + if (surface.integrationType === 'emulator' || surface.id.startsWith('emulator:')) { + emulators.push(surface) + } + } + } + + return emulators + }, [surfaceGroups]) return (
- - {surfaces && ( + + {surfaceGroups && (

 

diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx index be17288a04..1d0a3bec86 100644 --- a/webui/src/Surfaces/EditModal.jsx +++ b/webui/src/Surfaces/EditModal.jsx @@ -31,8 +31,9 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref let surfaceInfo = null if (surfaceId) { - for (const group of surfacesContext) { + for (const group of Object.values(surfacesContext)) { if (surfaceInfo) break + if (!group) continue for (const surface of group.surfaces) { if (surface.id === surfaceId) { @@ -49,8 +50,8 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref const groupId = surfaceInfo && !surfaceInfo.groupId ? surfaceId : rawGroupId let groupInfo = null if (groupId) { - for (const group of surfacesContext) { - if (group.id === groupId) { + for (const group of Object.values(surfacesContext)) { + if (group && group.id === groupId) { groupInfo = group break } @@ -114,7 +115,8 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref // If surface disappears/disconnects, hide this const onlineSurfaceIds = new Set() - for (const group of surfacesContext) { + for (const group of Object.values(surfacesContext)) { + if (!group) continue for (const surface of group.surfaces) { if (surface.isConnected) { onlineSurfaceIds.add(surface.id) @@ -226,8 +228,8 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref > - {surfacesContext - .filter((group) => !group.isAutoGroup) + {Object.values(surfacesContext) + .filter((group) => group && !group.isAutoGroup) .map((group) => (
- {surfacesContext.map((group) => + {surfacesList.map((group) => group.isAutoGroup && (group.surfaces || []).length === 1 ? ( From b54666eff3032c12aa0e11f84f0009e0d7a372fc Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 27 Nov 2023 17:47:52 +0000 Subject: [PATCH 53/53] feat: port webui to typescript (#2660) --- .github/workflows/nodejs.yml | 17 +- lib/Controls/ActionRecorder.js | 28 +- lib/Controls/ActionRunner.js | 4 +- lib/Controls/ControlBase.js | 2 +- lib/Controls/ControlTypes/Button/Base.js | 10 +- lib/Controls/ControlTypes/Button/Normal.js | 24 +- lib/Controls/ControlTypes/PageDown.js | 6 +- lib/Controls/ControlTypes/PageNumber.js | 6 +- lib/Controls/ControlTypes/PageUp.js | 6 +- lib/Controls/ControlTypes/Triggers/Trigger.js | 22 +- lib/Controls/Controller.js | 6 +- lib/Controls/Fragments/FragmentActions.js | 6 +- lib/Controls/Fragments/FragmentFeedbacks.js | 10 +- lib/Controls/IControlFragments.js | 6 +- lib/Data/ImportExport.js | 131 ++--- lib/Data/Upgrade.js | 2 +- lib/Data/Upgrades/v3tov4.js | 4 +- lib/Data/UserConfig.js | 4 +- lib/Graphics/Controller.js | 2 +- lib/Graphics/ImageResult.js | 2 +- lib/Graphics/Renderer.js | 16 +- lib/Instance/Controller.js | 10 +- lib/Instance/CustomVariable.js | 6 +- lib/Instance/Definitions.js | 76 +-- lib/Instance/Modules.js | 13 +- lib/Instance/Status.js | 6 +- lib/Instance/Variable.js | 7 +- lib/Instance/Wrapper.js | 16 +- lib/Internal/ActionRecorder.js | 10 +- lib/Internal/Controller.js | 18 +- lib/Internal/Controls.js | 56 ++- lib/Internal/CustomVariables.js | 18 +- lib/Internal/Instance.js | 8 +- lib/Internal/Surface.js | 45 +- lib/Internal/System.js | 4 +- lib/Internal/Triggers.js | 7 +- lib/Internal/Types.ts | 54 +-- lib/Internal/Variables.js | 9 +- lib/Log/Controller.js | 17 +- lib/Page/Controller.js | 12 +- lib/Resources/EventDefinitions.js | 16 +- lib/Resources/Util.js | 14 +- lib/Service/BonjourDiscovery.js | 9 +- lib/Service/EmberPlus.js | 8 +- lib/Shared/Import.js | 8 +- lib/{Data => Shared}/Model/ActionModel.ts | 0 lib/Shared/Model/ActionRecorderModel.ts | 21 + lib/{Data => Shared}/Model/ButtonModel.ts | 0 lib/Shared/Model/Common.ts | 72 +++ .../Model/CustomVariableModel.ts | 0 lib/{Data => Shared}/Model/EventModel.ts | 0 lib/Shared/Model/ExportFormat.ts | 1 + lib/{Data => Shared}/Model/ExportModel.ts | 10 +- lib/{Data => Shared}/Model/FeedbackModel.ts | 0 lib/Shared/Model/ImportExport.ts | 44 ++ lib/Shared/Model/LogLine.ts | 6 + lib/Shared/Model/Options.ts | 126 +++++ lib/{Data => Shared}/Model/PageModel.ts | 0 lib/Shared/Model/Presets.ts | 48 ++ lib/{Data => Shared}/Model/README.md | 2 +- lib/{Data => Shared}/Model/StyleModel.ts | 0 lib/Shared/Model/Surfaces.ts | 18 + lib/{Data => Shared}/Model/TriggerModel.ts | 6 + lib/{Data => Shared}/Model/UserConfigModel.ts | 11 +- lib/Shared/Model/Variables.ts | 7 + lib/Surface/Controller.js | 47 +- lib/Surface/IP/ElgatoEmulator.js | 19 +- lib/UI/Handler.js | 1 + lib/UI/Update.js | 2 +- webui/index.html | 2 +- webui/package.json | 10 +- webui/src/{App.jsx => App.tsx} | 62 ++- ...{ActionRecorder.jsx => ActionRecorder.tsx} | 246 ++++++---- ...nGridActions.jsx => ButtonGridActions.tsx} | 31 +- ...tonGridHeader.jsx => ButtonGridHeader.tsx} | 37 +- ...uttonGridPanel.jsx => ButtonGridPanel.tsx} | 190 ++++---- webui/src/Buttons/ButtonInfiniteGrid.jsx | 269 ----------- webui/src/Buttons/ButtonInfiniteGrid.tsx | 317 +++++++++++++ ...iablesList.jsx => CustomVariablesList.tsx} | 90 ++-- .../{EditButton.jsx => EditButton.tsx} | 396 +++++++++------- .../src/Buttons/{Presets.jsx => Presets.tsx} | 117 +++-- .../Buttons/{Variables.jsx => Variables.tsx} | 37 +- webui/src/Buttons/{index.jsx => index.tsx} | 47 +- webui/src/{CSwitch.jsx => CSwitch.tsx} | 20 +- .../{RegionPanel.jsx => RegionPanel.tsx} | 34 +- .../src/Cloud/{UserPass.jsx => UserPass.tsx} | 23 +- webui/src/Cloud/{index.jsx => index.tsx} | 67 ++- webui/src/{CloudPage.jsx => CloudPage.tsx} | 0 ...InputField.jsx => AlignmentInputField.tsx} | 10 +- ...tField.jsx => BonjourDeviceInputField.tsx} | 34 +- webui/src/Components/ButtonPreview.jsx | 60 --- webui/src/Components/ButtonPreview.tsx | 133 ++++++ webui/src/Components/Card.jsx | 14 - ...xInputField.jsx => CheckboxInputField.tsx} | 14 +- ...olorInputField.jsx => ColorInputField.tsx} | 60 ++- webui/src/Components/ConfirmExportModal.jsx | 76 --- webui/src/Components/ConfirmExportModal.tsx | 88 ++++ ...nInputField.jsx => DropdownInputField.tsx} | 60 ++- webui/src/Components/GenericConfirmModal.jsx | 66 --- webui/src/Components/GenericConfirmModal.tsx | 83 ++++ webui/src/Components/Notifications.jsx | 76 --- webui/src/Components/Notifications.tsx | 98 ++++ ...berInputField.jsx => NumberInputField.tsx} | 41 +- .../{PNGInputField.jsx => PNGInputField.tsx} | 31 +- ...{TextInputField.jsx => TextInputField.tsx} | 43 +- ...{VariablesTable.jsx => VariablesTable.tsx} | 103 ++-- webui/src/Components/{index.jsx => index.tsx} | 1 - ...onnectionDebug.jsx => ConnectionDebug.tsx} | 81 ++-- .../{AddConnection.jsx => AddConnection.tsx} | 35 +- ...nEditPanel.jsx => ConnectionEditPanel.tsx} | 99 ++-- ...{ConnectionList.jsx => ConnectionList.tsx} | 92 +++- .../Connections/ConnectionVariablesModal.jsx | 42 -- .../Connections/ConnectionVariablesModal.tsx | 46 ++ .../{HelpModal.jsx => HelpModal.tsx} | 24 +- .../src/Connections/{index.jsx => index.tsx} | 27 +- webui/src/{Constants.js => Constants.ts} | 11 +- .../src/{ContextData.jsx => ContextData.tsx} | 134 +++--- ...ctionSetEditor.jsx => ActionSetEditor.tsx} | 270 +++++++---- .../Controls/{AddModal.jsx => AddModal.tsx} | 73 ++- ...nStyleConfig.jsx => ButtonStyleConfig.tsx} | 70 ++- ...onsEditor.jsx => ControlOptionsEditor.tsx} | 32 +- ...{FeedbackEditor.jsx => FeedbackEditor.tsx} | 255 +++++----- ...eFields.jsx => InternalInstanceFields.tsx} | 119 ++++- ...tonPreview.jsx => OptionButtonPreview.tsx} | 18 +- ...nsInputField.jsx => OptionsInputField.tsx} | 34 +- .../Emulator/{Emulator.jsx => Emulator.tsx} | 117 +++-- webui/src/Emulator/{Keymaps.js => Keymaps.ts} | 8 +- webui/src/Emulator/{List.jsx => List.tsx} | 26 +- ...{GettingStarted.jsx => GettingStarted.tsx} | 121 +++-- ...{CollapseHelper.jsx => CollapseHelper.tsx} | 30 +- webui/src/Helpers/Window.jsx | 14 - webui/src/Helpers/Window.tsx | 28 ++ ...entInnerSize.js => useElementInnerSize.ts} | 7 +- ...sBeenRendered.js => useHasBeenRendered.ts} | 2 +- webui/src/Hooks/useOptionsAndIsVisible.ts | 60 +++ .../{usePagePicker.js => usePagePicker.ts} | 7 +- ...ription.js => usePagesInfoSubscription.ts} | 12 +- webui/src/Hooks/useScrollPosition.js | 30 -- webui/src/Hooks/useScrollPosition.ts | 36 ++ ...RenderCache.js => useSharedRenderCache.ts} | 17 +- ...iption.js => useUserConfigSubscription.ts} | 24 +- .../ImportExport/{Export.jsx => Export.tsx} | 185 ++++---- webui/src/ImportExport/ExportFormat.jsx | 18 - webui/src/ImportExport/ExportFormat.tsx | 32 ++ .../Import/{Full.jsx => Full.tsx} | 37 +- .../Import/{Page.jsx => Page.tsx} | 55 ++- .../Import/{Triggers.jsx => Triggers.tsx} | 26 +- .../Import/{index.jsx => index.tsx} | 16 +- .../src/ImportExport/{Reset.jsx => Reset.tsx} | 324 +++++++------ .../src/ImportExport/{index.jsx => index.tsx} | 31 +- webui/src/Layout/{Header.jsx => Header.tsx} | 41 +- webui/src/Layout/{Sidebar.jsx => Sidebar.tsx} | 7 +- webui/src/{LogPanel.jsx => LogPanel.tsx} | 69 ++- webui/src/Surfaces/AddGroupModal.jsx | 79 ---- webui/src/Surfaces/AddGroupModal.tsx | 96 ++++ webui/src/Surfaces/EditModal.jsx | 436 ----------------- webui/src/Surfaces/EditModal.tsx | 447 ++++++++++++++++++ webui/src/Surfaces/{index.jsx => index.tsx} | 118 +++-- ...uttonsFromPage.jsx => ButtonsFromPage.tsx} | 47 +- ...{ConfigurePanel.jsx => ConfigurePanel.tsx} | 14 +- webui/src/TabletView/{index.jsx => index.tsx} | 56 ++- .../Triggers/{EditPanel.jsx => EditPanel.tsx} | 71 ++- .../{EventEditor.jsx => EventEditor.tsx} | 162 ++++--- webui/src/Triggers/{index.jsx => index.tsx} | 71 +-- ...wordConfig.jsx => AdminPasswordConfig.tsx} | 13 +- .../{ArtnetConfig.jsx => ArtnetConfig.tsx} | 11 +- ...{ArtnetProtocol.jsx => ArtnetProtocol.tsx} | 0 .../{ButtonsConfig.jsx => ButtonsConfig.tsx} | 11 +- ...mberPlusConfig.jsx => EmberPlusConfig.tsx} | 11 +- ...imentsConfig.jsx => ExperimentsConfig.tsx} | 13 +- .../{GridConfig.jsx => GridConfig.tsx} | 93 ++-- .../{HttpConfig.jsx => HttpConfig.tsx} | 13 +- .../{HttpProtocol.jsx => HttpProtocol.tsx} | 0 .../{HttpsConfig.jsx => HttpsConfig.tsx} | 21 +- .../{OscConfig.jsx => OscConfig.tsx} | 11 +- .../{OscProtocol.jsx => OscProtocol.tsx} | 7 +- ...LockoutConfig.jsx => PinLockoutConfig.tsx} | 11 +- ...{RosstalkConfig.jsx => RosstalkConfig.tsx} | 11 +- ...stalkProtocol.jsx => RosstalkProtocol.tsx} | 0 webui/src/UserConfig/SatelliteConfig.jsx | 18 - webui/src/UserConfig/SatelliteConfig.tsx | 25 + ...{SurfacesConfig.jsx => SurfacesConfig.tsx} | 13 +- .../{TcpConfig.jsx => TcpConfig.tsx} | 11 +- ...{TcpUdpProtocol.jsx => TcpUdpProtocol.tsx} | 10 +- .../{UdpConfig.jsx => UdpConfig.tsx} | 11 +- ...verConfig.jsx => VideohubServerConfig.tsx} | 13 +- webui/src/UserConfig/{index.jsx => index.tsx} | 2 + .../Wizard/{ApplyStep.jsx => ApplyStep.tsx} | 19 +- .../Wizard/{BeginStep.jsx => BeginStep.tsx} | 0 .../Wizard/{FinishStep.jsx => FinishStep.tsx} | 8 +- .../{PasswordStep.jsx => PasswordStep.tsx} | 8 +- .../{ServicesStep.jsx => ServicesStep.tsx} | 8 +- .../{SurfacesStep.jsx => SurfacesStep.tsx} | 12 +- webui/src/Wizard/{index.jsx => index.tsx} | 98 ++-- webui/src/{index.jsx => index.tsx} | 2 +- webui/src/scss/_common.scss | 4 + webui/src/{util.jsx => util.tsx} | 135 ++++-- webui/tsconfig.json | 38 ++ webui/yarn.lock | 41 +- 199 files changed, 5767 insertions(+), 3662 deletions(-) rename lib/{Data => Shared}/Model/ActionModel.ts (100%) create mode 100644 lib/Shared/Model/ActionRecorderModel.ts rename lib/{Data => Shared}/Model/ButtonModel.ts (100%) create mode 100644 lib/Shared/Model/Common.ts rename lib/{Data => Shared}/Model/CustomVariableModel.ts (100%) rename lib/{Data => Shared}/Model/EventModel.ts (100%) create mode 100644 lib/Shared/Model/ExportFormat.ts rename lib/{Data => Shared}/Model/ExportModel.ts (92%) rename lib/{Data => Shared}/Model/FeedbackModel.ts (100%) create mode 100644 lib/Shared/Model/ImportExport.ts create mode 100644 lib/Shared/Model/LogLine.ts create mode 100644 lib/Shared/Model/Options.ts rename lib/{Data => Shared}/Model/PageModel.ts (100%) create mode 100644 lib/Shared/Model/Presets.ts rename lib/{Data => Shared}/Model/README.md (65%) rename lib/{Data => Shared}/Model/StyleModel.ts (100%) create mode 100644 lib/Shared/Model/Surfaces.ts rename lib/{Data => Shared}/Model/TriggerModel.ts (77%) rename lib/{Data => Shared}/Model/UserConfigModel.ts (90%) create mode 100644 lib/Shared/Model/Variables.ts rename webui/src/{App.jsx => App.tsx} (90%) rename webui/src/Buttons/{ActionRecorder.jsx => ActionRecorder.tsx} (70%) rename webui/src/Buttons/{ButtonGridActions.jsx => ButtonGridActions.tsx} (83%) rename webui/src/Buttons/{ButtonGridHeader.jsx => ButtonGridHeader.tsx} (63%) rename webui/src/Buttons/{ButtonGridPanel.jsx => ButtonGridPanel.tsx} (61%) delete mode 100644 webui/src/Buttons/ButtonInfiniteGrid.jsx create mode 100644 webui/src/Buttons/ButtonInfiniteGrid.tsx rename webui/src/Buttons/{CustomVariablesList.jsx => CustomVariablesList.tsx} (82%) rename webui/src/Buttons/{EditButton.jsx => EditButton.tsx} (68%) rename webui/src/Buttons/{Presets.jsx => Presets.tsx} (63%) rename webui/src/Buttons/{Variables.jsx => Variables.tsx} (76%) rename webui/src/Buttons/{index.jsx => index.tsx} (87%) rename webui/src/{CSwitch.jsx => CSwitch.tsx} (73%) rename webui/src/Cloud/{RegionPanel.jsx => RegionPanel.tsx} (70%) rename webui/src/Cloud/{UserPass.jsx => UserPass.tsx} (67%) rename webui/src/Cloud/{index.jsx => index.tsx} (85%) rename webui/src/{CloudPage.jsx => CloudPage.tsx} (100%) rename webui/src/Components/{AlignmentInputField.jsx => AlignmentInputField.tsx} (59%) rename webui/src/Components/{BonjourDeviceInputField.jsx => BonjourDeviceInputField.tsx} (69%) delete mode 100644 webui/src/Components/ButtonPreview.jsx create mode 100644 webui/src/Components/ButtonPreview.tsx delete mode 100644 webui/src/Components/Card.jsx rename webui/src/Components/{CheckboxInputField.jsx => CheckboxInputField.tsx} (65%) rename webui/src/Components/{ColorInputField.jsx => ColorInputField.tsx} (77%) delete mode 100644 webui/src/Components/ConfirmExportModal.jsx create mode 100644 webui/src/Components/ConfirmExportModal.tsx rename webui/src/Components/{DropdownInputField.jsx => DropdownInputField.tsx} (73%) delete mode 100644 webui/src/Components/GenericConfirmModal.jsx create mode 100644 webui/src/Components/GenericConfirmModal.tsx delete mode 100644 webui/src/Components/Notifications.jsx create mode 100644 webui/src/Components/Notifications.tsx rename webui/src/Components/{NumberInputField.jsx => NumberInputField.tsx} (71%) rename webui/src/Components/{PNGInputField.jsx => PNGInputField.tsx} (76%) rename webui/src/Components/{TextInputField.jsx => TextInputField.tsx} (79%) rename webui/src/Components/{VariablesTable.jsx => VariablesTable.tsx} (65%) rename webui/src/Components/{index.jsx => index.tsx} (92%) rename webui/src/{ConnectionDebug.jsx => ConnectionDebug.tsx} (81%) rename webui/src/Connections/{AddConnection.jsx => AddConnection.tsx} (81%) rename webui/src/Connections/{ConnectionEditPanel.jsx => ConnectionEditPanel.tsx} (73%) rename webui/src/Connections/{ConnectionList.jsx => ConnectionList.tsx} (80%) delete mode 100644 webui/src/Connections/ConnectionVariablesModal.jsx create mode 100644 webui/src/Connections/ConnectionVariablesModal.tsx rename webui/src/Connections/{HelpModal.jsx => HelpModal.tsx} (71%) rename webui/src/Connections/{index.jsx => index.tsx} (81%) rename webui/src/{Constants.js => Constants.ts} (57%) rename webui/src/{ContextData.jsx => ContextData.tsx} (63%) rename webui/src/Controls/{ActionSetEditor.jsx => ActionSetEditor.tsx} (71%) rename webui/src/Controls/{AddModal.jsx => AddModal.tsx} (74%) rename webui/src/Controls/{ButtonStyleConfig.jsx => ButtonStyleConfig.tsx} (73%) rename webui/src/Controls/{ControlOptionsEditor.jsx => ControlOptionsEditor.tsx} (82%) rename webui/src/Controls/{FeedbackEditor.jsx => FeedbackEditor.tsx} (75%) rename webui/src/Controls/{InternalInstanceFields.jsx => InternalInstanceFields.tsx} (69%) rename webui/src/Controls/{OptionButtonPreview.jsx => OptionButtonPreview.tsx} (65%) rename webui/src/Controls/{OptionsInputField.jsx => OptionsInputField.tsx} (76%) rename webui/src/Emulator/{Emulator.jsx => Emulator.tsx} (66%) rename webui/src/Emulator/{Keymaps.js => Keymaps.ts} (76%) rename webui/src/Emulator/{List.jsx => List.tsx} (75%) rename webui/src/{GettingStarted.jsx => GettingStarted.tsx} (66%) rename webui/src/Helpers/{CollapseHelper.jsx => CollapseHelper.tsx} (73%) delete mode 100644 webui/src/Helpers/Window.jsx create mode 100644 webui/src/Helpers/Window.tsx rename webui/src/Hooks/{useElementInnerSize.js => useElementInnerSize.ts} (71%) rename webui/src/Hooks/{useHasBeenRendered.js => useHasBeenRendered.ts} (77%) create mode 100644 webui/src/Hooks/useOptionsAndIsVisible.ts rename webui/src/Hooks/{usePagePicker.js => usePagePicker.ts} (74%) rename webui/src/Hooks/{usePagesInfoSubscription.js => usePagesInfoSubscription.ts} (68%) delete mode 100644 webui/src/Hooks/useScrollPosition.js create mode 100644 webui/src/Hooks/useScrollPosition.ts rename webui/src/Hooks/{useSharedRenderCache.js => useSharedRenderCache.ts} (75%) rename webui/src/Hooks/{useUserConfigSubscription.js => useUserConfigSubscription.ts} (58%) rename webui/src/ImportExport/{Export.jsx => Export.tsx} (50%) delete mode 100644 webui/src/ImportExport/ExportFormat.jsx create mode 100644 webui/src/ImportExport/ExportFormat.tsx rename webui/src/ImportExport/Import/{Full.jsx => Full.tsx} (85%) rename webui/src/ImportExport/Import/{Page.jsx => Page.tsx} (78%) rename webui/src/ImportExport/Import/{Triggers.jsx => Triggers.tsx} (76%) rename webui/src/ImportExport/Import/{index.jsx => index.tsx} (70%) rename webui/src/ImportExport/{Reset.jsx => Reset.tsx} (54%) rename webui/src/ImportExport/{index.jsx => index.tsx} (77%) rename webui/src/Layout/{Header.jsx => Header.tsx} (62%) rename webui/src/Layout/{Sidebar.jsx => Sidebar.tsx} (96%) rename webui/src/{LogPanel.jsx => LogPanel.tsx} (79%) delete mode 100644 webui/src/Surfaces/AddGroupModal.jsx create mode 100644 webui/src/Surfaces/AddGroupModal.tsx delete mode 100644 webui/src/Surfaces/EditModal.jsx create mode 100644 webui/src/Surfaces/EditModal.tsx rename webui/src/Surfaces/{index.jsx => index.tsx} (73%) rename webui/src/TabletView/{ButtonsFromPage.jsx => ButtonsFromPage.tsx} (70%) rename webui/src/TabletView/{ConfigurePanel.jsx => ConfigurePanel.tsx} (93%) rename webui/src/TabletView/{index.jsx => index.tsx} (76%) rename webui/src/Triggers/{EditPanel.jsx => EditPanel.tsx} (74%) rename webui/src/Triggers/{EventEditor.jsx => EventEditor.tsx} (74%) rename webui/src/Triggers/{index.jsx => index.tsx} (83%) rename webui/src/UserConfig/{AdminPasswordConfig.jsx => AdminPasswordConfig.tsx} (85%) rename webui/src/UserConfig/{ArtnetConfig.jsx => ArtnetConfig.tsx} (81%) rename webui/src/UserConfig/{ArtnetProtocol.jsx => ArtnetProtocol.tsx} (100%) rename webui/src/UserConfig/{ButtonsConfig.jsx => ButtonsConfig.tsx} (83%) rename webui/src/UserConfig/{EmberPlusConfig.jsx => EmberPlusConfig.tsx} (74%) rename webui/src/UserConfig/{ExperimentsConfig.jsx => ExperimentsConfig.tsx} (76%) rename webui/src/UserConfig/{GridConfig.jsx => GridConfig.tsx} (76%) rename webui/src/UserConfig/{HttpConfig.jsx => HttpConfig.tsx} (76%) rename webui/src/UserConfig/{HttpProtocol.jsx => HttpProtocol.tsx} (100%) rename webui/src/UserConfig/{HttpsConfig.jsx => HttpsConfig.tsx} (93%) rename webui/src/UserConfig/{OscConfig.jsx => OscConfig.tsx} (83%) rename webui/src/UserConfig/{OscProtocol.jsx => OscProtocol.tsx} (96%) rename webui/src/UserConfig/{PinLockoutConfig.jsx => PinLockoutConfig.tsx} (86%) rename webui/src/UserConfig/{RosstalkConfig.jsx => RosstalkConfig.tsx} (69%) rename webui/src/UserConfig/{RosstalkProtocol.jsx => RosstalkProtocol.tsx} (100%) delete mode 100644 webui/src/UserConfig/SatelliteConfig.jsx create mode 100644 webui/src/UserConfig/SatelliteConfig.tsx rename webui/src/UserConfig/{SurfacesConfig.jsx => SurfacesConfig.tsx} (88%) rename webui/src/UserConfig/{TcpConfig.jsx => TcpConfig.tsx} (83%) rename webui/src/UserConfig/{TcpUdpProtocol.jsx => TcpUdpProtocol.tsx} (94%) rename webui/src/UserConfig/{UdpConfig.jsx => UdpConfig.tsx} (83%) rename webui/src/UserConfig/{VideohubServerConfig.jsx => VideohubServerConfig.tsx} (68%) rename webui/src/UserConfig/{index.jsx => index.tsx} (99%) rename webui/src/Wizard/{ApplyStep.jsx => ApplyStep.tsx} (91%) rename webui/src/Wizard/{BeginStep.jsx => BeginStep.tsx} (100%) rename webui/src/Wizard/{FinishStep.jsx => FinishStep.tsx} (86%) rename webui/src/Wizard/{PasswordStep.jsx => PasswordStep.tsx} (85%) rename webui/src/Wizard/{ServicesStep.jsx => ServicesStep.tsx} (94%) rename webui/src/Wizard/{SurfacesStep.jsx => SurfacesStep.tsx} (86%) rename webui/src/Wizard/{index.jsx => index.tsx} (60%) rename webui/src/{index.jsx => index.tsx} (99%) rename webui/src/{util.jsx => util.tsx} (52%) create mode 100644 webui/tsconfig.json diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 77c32b3624..9a771cfe68 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -13,17 +13,30 @@ jobs: with: node-version-file: '.node-version' - - name: Prepare + - name: Prepare (server) run: | # try and avoid timeout errors yarn config set network-timeout 100000 -g yarn --frozen-lockfile - - name: Check types + - name: Check types (server) run: | yarn check-types + - name: Prepare (client) + run: | + cd webui + # try and avoid timeout errors + yarn config set network-timeout 100000 -g + + yarn --frozen-lockfile + + - name: Check types (client) + run: | + cd webui + yarn check-types + linux64: runs-on: ubuntu-20.04 needs: check-types diff --git a/lib/Controls/ActionRecorder.js b/lib/Controls/ActionRecorder.js index f1535eb9e7..ea559736e7 100644 --- a/lib/Controls/ActionRecorder.js +++ b/lib/Controls/ActionRecorder.js @@ -15,31 +15,9 @@ function SessionRoom(id) { } /** - * @typedef {{ - * id: string - * connectionIds: string[] - * isRunning: boolean - * actionDelay: number - * actions: RecordActionTmp[] - * }} RecordSessionInfo - */ - -/** - * @typedef {{ - * connectionIds: string[] - * }} RecordSessionListInfo - */ - -/** - * TODO - consolidate - * @typedef {{ - * id: string - * instance: string - * action: string - * delay: number - * options: Record - * uniquenessId: string | undefined - * }} RecordActionTmp + * @typedef {import('../Shared/Model/ActionRecorderModel.js').RecordSessionInfo} RecordSessionInfo + * @typedef {import('../Shared/Model/ActionRecorderModel.js').RecordSessionListInfo} RecordSessionListInfo + * @typedef {import('../Shared/Model/ActionRecorderModel.js').RecordActionTmp} RecordActionTmp */ /** diff --git a/lib/Controls/ActionRunner.js b/lib/Controls/ActionRunner.js index 6395872ef9..c110530a68 100644 --- a/lib/Controls/ActionRunner.js +++ b/lib/Controls/ActionRunner.js @@ -109,7 +109,7 @@ export default class ActionRunner extends CoreBase { /** * Run a single action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} extras * @access private */ @@ -143,7 +143,7 @@ export default class ActionRunner extends CoreBase { /** * Run multiple actions - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions0 + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions0 * @param {string} controlId * @param {boolean} relative_delay * @param {Omit} extras diff --git a/lib/Controls/ControlBase.js b/lib/Controls/ControlBase.js index 3b71c2f25a..b610698495 100644 --- a/lib/Controls/ControlBase.js +++ b/lib/Controls/ControlBase.js @@ -151,7 +151,7 @@ export default class ControlBase extends CoreBase { /** * Get the complete style object of a button - * @returns {import('../Data/Model/StyleModel.js').DrawStyleModel | null} the processed style of the button + * @returns {import('../Shared/Model/StyleModel.js').DrawStyleModel | null} the processed style of the button * @access public */ getDrawStyle() { diff --git a/lib/Controls/ControlTypes/Button/Base.js b/lib/Controls/ControlTypes/Button/Base.js index 6b6ae189bc..ddaf25cafe 100644 --- a/lib/Controls/ControlTypes/Button/Base.js +++ b/lib/Controls/ControlTypes/Button/Base.js @@ -11,8 +11,8 @@ import { } from '../../IControlFragments.js' /** - * @typedef {import('../../../Data/Model/ActionModel.js').ActionInstance} ActionInstance - * @typedef {import('../../../Data/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance + * @typedef {import('../../../Shared/Model/ActionModel.js').ActionInstance} ActionInstance + * @typedef {import('../../../Shared/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance */ /** @@ -65,7 +65,7 @@ export default class ButtonControlBase extends ControlBase { /** * The defaults options for a button - * @type {import('../../../Data/Model/ButtonModel.js').ButtonOptionsBase} + * @type {import('../../../Shared/Model/ButtonModel.js').ButtonOptionsBase} * @access public * @static */ @@ -90,7 +90,7 @@ export default class ButtonControlBase extends ControlBase { /** * The config of this button - * @type {import('../../../Data/Model/ButtonModel.js').ButtonOptionsBase} + * @type {import('../../../Shared/Model/ButtonModel.js').ButtonOptionsBase} */ options @@ -259,7 +259,7 @@ export default class ButtonControlBase extends ControlBase { /** * Get the complete style object of a button - * @returns {import('../../../Data/Model/StyleModel.js').DrawStyleButtonModel} the processed style of the button + * @returns {import('../../../Shared/Model/StyleModel.js').DrawStyleButtonModel} the processed style of the button * @access public */ getDrawStyle() { diff --git a/lib/Controls/ControlTypes/Button/Normal.js b/lib/Controls/ControlTypes/Button/Normal.js index cee6bcb9e7..03f2ea43f0 100644 --- a/lib/Controls/ControlTypes/Button/Normal.js +++ b/lib/Controls/ControlTypes/Button/Normal.js @@ -12,8 +12,8 @@ import { } from '../../IControlFragments.js' /** - * @typedef {import('../../../Data/Model/ActionModel.js').ActionInstance} ActionInstance - * @typedef {import('../../../Data/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance + * @typedef {import('../../../Shared/Model/ActionModel.js').ActionInstance} ActionInstance + * @typedef {import('../../../Shared/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance */ /** @@ -70,7 +70,7 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * The defaults options for a step - * @type {import('../../../Data/Model/ActionModel.js').ActionStepOptions} + * @type {import('../../../Shared/Model/ActionModel.js').ActionStepOptions} * @access public * @static */ @@ -100,14 +100,14 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * The config of this button - * @type {import('../../../Data/Model/ButtonModel.js').NormalButtonOptions} + * @type {import('../../../Shared/Model/ButtonModel.js').NormalButtonOptions} */ options /** * @param {import('../../../Registry.js').default} registry - the application core * @param {string} controlId - id of the control - * @param {import('../../../Data/Model/ButtonModel.js').NormalButtonModel | null} storage - persisted storage object + * @param {import('../../../Shared/Model/ButtonModel.js').NormalButtonModel | null} storage - persisted storage object * @param {boolean} isImport - if this is importing a button, not creating at startup */ constructor(registry, controlId, storage, isImport) { @@ -544,7 +544,7 @@ export default class ControlButtonNormal extends ButtonControlBase { /** * Get the complete style object of a button - * @returns {import('../../../Data/Model/StyleModel.js').DrawStyleButtonModel} the processed style of the button + * @returns {import('../../../Shared/Model/StyleModel.js').DrawStyleButtonModel} the processed style of the button * @override */ getDrawStyle() { @@ -559,11 +559,11 @@ export default class ControlButtonNormal extends ButtonControlBase { } /** - * @param {import('../../../Data/Model/ActionModel.js').ActionSetsModel=} existingActions - * @param {import("../../../Data/Model/ActionModel.js").ActionStepOptions=} existingOptions + * @param {import('../../../Shared/Model/ActionModel.js').ActionSetsModel=} existingActions + * @param {import("../../../Shared/Model/ActionModel.js").ActionStepOptions=} existingOptions */ #getNewStepValue(existingActions, existingOptions) { - /** @type {import('../../../Data/Model/ActionModel.js').ActionSetsModel} */ + /** @type {import('../../../Shared/Model/ActionModel.js').ActionSetsModel} */ const action_sets = existingActions || { down: [], up: [], @@ -946,11 +946,11 @@ export default class ControlButtonNormal extends ButtonControlBase { * Convert this control to JSON * To be sent to the client and written to the db * @param {boolean} clone - Whether to return a cloned object - * @returns {import('../../../Data/Model/ButtonModel.js').NormalButtonModel} + * @returns {import('../../../Shared/Model/ButtonModel.js').NormalButtonModel} * @access public */ toJSON(clone = true) { - /** @type {import('../../../Data/Model/ButtonModel.js').NormalButtonSteps} */ + /** @type {import('../../../Shared/Model/ButtonModel.js').NormalButtonSteps} */ const stepsJson = {} for (const [id, step] of Object.entries(this.steps)) { stepsJson[id] = { @@ -959,7 +959,7 @@ export default class ControlButtonNormal extends ButtonControlBase { } } - /** @type {import('../../../Data/Model/ButtonModel.js').NormalButtonModel} */ + /** @type {import('../../../Shared/Model/ButtonModel.js').NormalButtonModel} */ const obj = { type: this.type, style: this.feedbacks.baseStyle, diff --git a/lib/Controls/ControlTypes/PageDown.js b/lib/Controls/ControlTypes/PageDown.js index c0d8d9d14b..3a45302795 100644 --- a/lib/Controls/ControlTypes/PageDown.js +++ b/lib/Controls/ControlTypes/PageDown.js @@ -89,7 +89,7 @@ export default class ControlButtonPageDown extends ControlBase { /** * @param {import('../../Registry.js').default} registry - the application core * @param {string} controlId - id of the control - * @param {import('../../Data/Model/ButtonModel.js').PageDownButtonModel|null} storage - persisted storage object + * @param {import('../../Shared/Model/ButtonModel.js').PageDownButtonModel|null} storage - persisted storage object * @param {boolean} isImport - if this is importing a button, not creating at startup */ constructor(registry, controlId, storage, isImport) { @@ -111,7 +111,7 @@ export default class ControlButtonPageDown extends ControlBase { /** * Get the complete style object of a button - * @returns {import('../../Data/Model/StyleModel.js').DrawStyleModel} the processed style of the button + * @returns {import('../../Shared/Model/StyleModel.js').DrawStyleModel} the processed style of the button * @access public */ getDrawStyle() { @@ -153,7 +153,7 @@ export default class ControlButtonPageDown extends ControlBase { * Convert this control to JSON * To be sent to the client and written to the db * @param {boolean} _clone - Whether to return a cloned object - * @returns {import('../../Data/Model/ButtonModel.js').PageDownButtonModel} + * @returns {import('../../Shared/Model/ButtonModel.js').PageDownButtonModel} * @access public */ toJSON(_clone = true) { diff --git a/lib/Controls/ControlTypes/PageNumber.js b/lib/Controls/ControlTypes/PageNumber.js index 3cdba2d5e9..a5b18e6e37 100644 --- a/lib/Controls/ControlTypes/PageNumber.js +++ b/lib/Controls/ControlTypes/PageNumber.js @@ -89,7 +89,7 @@ export default class ControlButtonPageNumber extends ControlBase { /** * @param {import('../../Registry.js').default} registry - the application core * @param {string} controlId - id of the control - * @param {import('../../Data/Model/ButtonModel.js').PageNumberButtonModel | null} storage - persisted storage object + * @param {import('../../Shared/Model/ButtonModel.js').PageNumberButtonModel | null} storage - persisted storage object * @param {boolean} isImport - if this is importing a button, not creating at startup */ constructor(registry, controlId, storage, isImport) { @@ -112,7 +112,7 @@ export default class ControlButtonPageNumber extends ControlBase { /** * Get the complete style object of a button - * @returns {import('../../Data/Model/StyleModel.js').DrawStyleModel} the processed style of the button + * @returns {import('../../Shared/Model/StyleModel.js').DrawStyleModel} the processed style of the button * @access public */ getDrawStyle() { @@ -154,7 +154,7 @@ export default class ControlButtonPageNumber extends ControlBase { * Convert this control to JSON * To be sent to the client and written to the db * @param {boolean} _clone - Whether to return a cloned object - * @returns {import('../../Data/Model/ButtonModel.js').PageNumberButtonModel} + * @returns {import('../../Shared/Model/ButtonModel.js').PageNumberButtonModel} * @access public */ toJSON(_clone = true) { diff --git a/lib/Controls/ControlTypes/PageUp.js b/lib/Controls/ControlTypes/PageUp.js index 4aedf86ca7..aa6169042c 100644 --- a/lib/Controls/ControlTypes/PageUp.js +++ b/lib/Controls/ControlTypes/PageUp.js @@ -89,7 +89,7 @@ export default class ControlButtonPageUp extends ControlBase { /** * @param {import('../../Registry.js').default} registry - the application core * @param {string} controlId - id of the control - * @param {import('../../Data/Model/ButtonModel.js').PageUpButtonModel | null} storage - persisted storage object + * @param {import('../../Shared/Model/ButtonModel.js').PageUpButtonModel | null} storage - persisted storage object * @param {boolean} isImport - if this is importing a button, not creating at startup */ constructor(registry, controlId, storage, isImport) { @@ -111,7 +111,7 @@ export default class ControlButtonPageUp extends ControlBase { /** * Get the complete style object of a button - * @returns {import('../../Data/Model/StyleModel.js').DrawStyleModel} the processed style of the button + * @returns {import('../../Shared/Model/StyleModel.js').DrawStyleModel} the processed style of the button * @access public */ getDrawStyle() { @@ -154,7 +154,7 @@ export default class ControlButtonPageUp extends ControlBase { * Convert this control to JSON * To be sent to the client and written to the db * @param {boolean} _clone - Whether to return a cloned object - * @returns {import('../../Data/Model/ButtonModel.js').PageUpButtonModel} + * @returns {import('../../Shared/Model/ButtonModel.js').PageUpButtonModel} * @access public */ toJSON(_clone = true) { diff --git a/lib/Controls/ControlTypes/Triggers/Trigger.js b/lib/Controls/ControlTypes/Triggers/Trigger.js index 51d1767afe..d1c4503cbf 100644 --- a/lib/Controls/ControlTypes/Triggers/Trigger.js +++ b/lib/Controls/ControlTypes/Triggers/Trigger.js @@ -24,9 +24,9 @@ import { } from '../../IControlFragments.js' /** - * @typedef {import('../../../Data/Model/ActionModel.js').ActionInstance} ActionInstance - * @typedef {import('../../../Data/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance - * @typedef {import('../../../Data/Model/EventModel.js').EventInstance} EventInstance + * @typedef {import('../../../Shared/Model/ActionModel.js').ActionInstance} ActionInstance + * @typedef {import('../../../Shared/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance + * @typedef {import('../../../Shared/Model/EventModel.js').EventInstance} EventInstance */ /** @@ -107,7 +107,7 @@ export default class ControlTrigger extends ControlBase { /** * The defaults options for a trigger - * @type {import('../../../Data/Model/TriggerModel.js').TriggerOptions} + * @type {import('../../../Shared/Model/TriggerModel.js').TriggerOptions} * @access public * @static */ @@ -169,7 +169,7 @@ export default class ControlTrigger extends ControlBase { /** * Basic trigger configuration - * @type {import('../../../Data/Model/TriggerModel.js').TriggerOptions} + * @type {import('../../../Shared/Model/TriggerModel.js').TriggerOptions} * @access public */ options @@ -198,7 +198,7 @@ export default class ControlTrigger extends ControlBase { * @param {import('../../../Registry.js').default} registry - the application core * @param {TriggerEvents} eventBus - the main trigger event bus * @param {string} controlId - id of the control - * @param {import('../../../Data/Model/TriggerModel.js').TriggerModel | null} storage - persisted storage object + * @param {import('../../../Shared/Model/TriggerModel.js').TriggerModel | null} storage - persisted storage object * @param {boolean} isImport - if this is importing a button, not creating at startup */ constructor(registry, eventBus, controlId, storage, isImport) { @@ -486,11 +486,11 @@ export default class ControlTrigger extends ControlBase { * Convert this control to JSON * To be sent to the client and written to the db * @param {boolean} clone - Whether to return a cloned object - * @returns {import('../../../Data/Model/TriggerModel.js').TriggerModel} + * @returns {import('../../../Shared/Model/TriggerModel.js').TriggerModel} * @access public */ toJSON(clone = true) { - /** @type {import('../../../Data/Model/TriggerModel.js').TriggerModel} */ + /** @type {import('../../../Shared/Model/TriggerModel.js').TriggerModel} */ const obj = { type: this.type, options: this.options, @@ -990,9 +990,5 @@ export default class ControlTrigger extends ControlBase { } /** - * @typedef {{ - * type: 'trigger' - * lastExecuted: number | undefined - * description: string - * } & import('../../../Data/Model/TriggerModel.js').TriggerOptions} ClientTriggerData + * @typedef {import('../../../Shared/Model/TriggerModel.js').ClientTriggerData} ClientTriggerData */ diff --git a/lib/Controls/Controller.js b/lib/Controls/Controller.js index 91e421156d..ef2aab3e29 100644 --- a/lib/Controls/Controller.js +++ b/lib/Controls/Controller.js @@ -15,7 +15,7 @@ import debounceFn from 'debounce-fn' export const TriggersListRoom = 'triggers:list' /** - * @typedef {import('../Data/Model/ButtonModel.js').SomeButtonModel | import('../Data/Model/TriggerModel.js').TriggerModel} SomeControlModel + * @typedef {import('../Shared/Model/ButtonModel.js').SomeButtonModel | import('../Shared/Model/TriggerModel.js').TriggerModel} SomeControlModel */ /** @@ -1318,7 +1318,7 @@ class ControlsController extends CoreBase { /** * Import a control * @param {import('../Resources/Util.js').ControlLocation} location Location to import to - * @param {import('../Data/Model/ButtonModel.js').SomeButtonModel} definition object to import + * @param {import('../Shared/Model/ButtonModel.js').SomeButtonModel} definition object to import * @param {string=} forceControlId * @returns * @access public @@ -1356,7 +1356,7 @@ class ControlsController extends CoreBase { /** * Import a trigger * @param {string} controlId Id for the trigger - * @param {import('../Data/Model/TriggerModel.js').TriggerModel} definition object to import + * @param {import('../Shared/Model/TriggerModel.js').TriggerModel} definition object to import * @returns * @access public */ diff --git a/lib/Controls/Fragments/FragmentActions.js b/lib/Controls/Fragments/FragmentActions.js index 9ad2bcf6a4..fbe77dc105 100644 --- a/lib/Controls/Fragments/FragmentActions.js +++ b/lib/Controls/Fragments/FragmentActions.js @@ -3,7 +3,7 @@ import { cloneDeep } from 'lodash-es' import { nanoid } from 'nanoid' /** - * @typedef {import('../../Data/Model/ActionModel.js').ActionInstance} ActionInstance + * @typedef {import('../../Shared/Model/ActionModel.js').ActionInstance} ActionInstance */ /** @@ -31,13 +31,13 @@ import { nanoid } from 'nanoid' export default class FragmentActions extends CoreBase { /** * The action-sets on this button - * @type {import('../../Data/Model/ActionModel.js').ActionSetsModel} + * @type {import('../../Shared/Model/ActionModel.js').ActionSetsModel} * @access public */ action_sets = {} /** - * @type {import('../../Data/Model/ActionModel.js').ActionStepOptions} + * @type {import('../../Shared/Model/ActionModel.js').ActionStepOptions} * @access public */ options diff --git a/lib/Controls/Fragments/FragmentFeedbacks.js b/lib/Controls/Fragments/FragmentFeedbacks.js index 025f3a267a..fbaba2fdad 100644 --- a/lib/Controls/Fragments/FragmentFeedbacks.js +++ b/lib/Controls/Fragments/FragmentFeedbacks.js @@ -4,7 +4,7 @@ import { cloneDeep, isEqual } from 'lodash-es' import { nanoid } from 'nanoid' /** - * @typedef {import('../../Data/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance + * @typedef {import('../../Shared/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance */ /** @@ -32,7 +32,7 @@ import { nanoid } from 'nanoid' export default class FragmentFeedbacks extends CoreBase { /** * The defaults style for a button - * @type {import('../../Data/Model/StyleModel.js').ButtonStyleProperties} + * @type {import('../../Shared/Model/StyleModel.js').ButtonStyleProperties} * @access public * @static */ @@ -50,7 +50,7 @@ export default class FragmentFeedbacks extends CoreBase { /** * The base style without feedbacks applied - * @type {import('../../Data/Model/StyleModel.js').ButtonStyleProperties} + * @type {import('../../Shared/Model/StyleModel.js').ButtonStyleProperties} * @access public */ baseStyle = cloneDeep(FragmentFeedbacks.DefaultStyle) @@ -560,13 +560,13 @@ export default class FragmentFeedbacks extends CoreBase { /** * Get the unparsed style for these feedbacks * Note: Does not clone the style - * @returns {import('../../Data/Model/StyleModel.js').UnparsedButtonStyle} the unprocessed style + * @returns {import('../../Shared/Model/StyleModel.js').UnparsedButtonStyle} the unprocessed style * @access public */ getUnparsedStyle() { if (this.#booleanOnly) throw new Error('FragmentFeedbacks not setup to use styles') - /** @type {import('../../Data/Model/StyleModel.js').UnparsedButtonStyle} */ + /** @type {import('../../Shared/Model/StyleModel.js').UnparsedButtonStyle} */ let style = { ...this.baseStyle, imageBuffers: [], diff --git a/lib/Controls/IControlFragments.js b/lib/Controls/IControlFragments.js index d22a26c7d0..9866715d17 100644 --- a/lib/Controls/IControlFragments.js +++ b/lib/Controls/IControlFragments.js @@ -6,9 +6,9 @@ import FragmentFeedbacks from './Fragments/FragmentFeedbacks.js' */ /** - * @typedef {import('../Data/Model/ActionModel.js').ActionInstance} ActionInstance - * @typedef {import('../Data/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance - * @typedef {import('../Data/Model/EventModel.js').EventInstance} EventInstance + * @typedef {import('../Shared/Model/ActionModel.js').ActionInstance} ActionInstance + * @typedef {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} FeedbackInstance + * @typedef {import('../Shared/Model/EventModel.js').EventInstance} EventInstance */ /** diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 07ed68d06d..f3c4f81841 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -63,7 +63,7 @@ const default_nav_buttons_definitions = [ * @param {import('winston').Logger} logger * @param {import("express").Response} res * @param {import("express").NextFunction} next - * @param {import("./Model/ExportModel.js").SomeExportv4} data + * @param {import("../Shared/Model/ExportModel.js").SomeExportv4} data * @param {string} filename * @param {'json-gz' | 'json' | undefined} format * @returns {void} @@ -99,11 +99,11 @@ function downloadBlob(logger, res, next, data, filename, format) { /** * - * @param {import('./Model/ExportModel.js').ExportPageContentv4} pageInfo - * @returns {import('./Model/ExportModel.js').ExportGridSize} + * @param {import('../Shared/Model/ExportModel.js').ExportPageContentv4} pageInfo + * @returns {import('../Shared/Model/UserConfigModel.js').UserConfigGridSize} */ const find_smallest_grid_for_page = (pageInfo) => { - /** @type {import('./Model/ExportModel.js').ExportGridSize} */ + /** @type {import('../Shared/Model/UserConfigModel.js').UserConfigGridSize} */ const gridSize = { minColumn: 0, maxColumn: 7, @@ -154,14 +154,14 @@ class DataImportExport extends CoreBase { * @param {Set} referencedConnectionIds * @param {Set} referencedConnectionLabels * @param {boolean} minimalExport - * @returns {import('./Model/ExportModel.js').ExportInstancesv4} + * @returns {import('../Shared/Model/ExportModel.js').ExportInstancesv4} */ const generate_export_for_referenced_instances = ( referencedConnectionIds, referencedConnectionLabels, minimalExport = false ) => { - /** @type {import('./Model/ExportModel.js').ExportInstancesv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportInstancesv4} */ const instancesExport = {} referencedConnectionIds.delete('internal') // Ignore the internal module @@ -183,10 +183,10 @@ class DataImportExport extends CoreBase { /** * * @param {*} triggerControls - * @returns {import('./Model/ExportModel.js').ExportTriggersListv4} + * @returns {import('../Shared/Model/ExportModel.js').ExportTriggersListv4} */ const generate_export_for_triggers = (triggerControls) => { - /** @type {Record} */ + /** @type {Record} */ const triggersExport = {} const referencedConnectionIds = new Set() const referencedConnectionLabels = new Set() @@ -257,7 +257,7 @@ class DataImportExport extends CoreBase { ) // Export file protocol version - /** @type {import('./Model/ExportModel.js').ExportPageModelv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportPageModelv4} */ const exp = { version: FILE_VERSION, type: 'page', @@ -273,13 +273,13 @@ class DataImportExport extends CoreBase { }) /** - * @param {Readonly} pageInfo + * @param {Readonly} pageInfo * @param {Set} referencedConnectionIds * @param {Set} referencedConnectionLabels - * @returns {import('./Model/ExportModel.js').ExportPageContentv4} + * @returns {import('../Shared/Model/ExportModel.js').ExportPageContentv4} */ const generatePageExportInfo = (pageInfo, referencedConnectionIds, referencedConnectionLabels) => { - /** @type {import('./Model/ExportModel.js').ExportPageContentv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportPageContentv4} */ const pageExport = { name: pageInfo.name, controls: {}, @@ -308,7 +308,7 @@ class DataImportExport extends CoreBase { */ const generateCustomExport = (config) => { // Export file protocol version - /** @type {import('./Model/ExportModel.js').ExportFullv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportFullv4} */ const exp = { version: FILE_VERSION, type: 'full', @@ -333,7 +333,7 @@ class DataImportExport extends CoreBase { } if (!config || !isFalsey(config.triggers)) { - /** @type {Record} */ + /** @type {Record} */ const triggersExport = {} for (const control of rawControls.values()) { if (control.type === 'trigger') { @@ -591,7 +591,7 @@ class DataImportExport extends CoreBase { } if (object.type === 'trigger_list') { - /** @type {import('./Model/ExportModel.js').ExportFullv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportFullv4} */ object = { type: 'full', version: FILE_VERSION, @@ -628,7 +628,7 @@ class DataImportExport extends CoreBase { } /** - * @param {import('./Model/ExportModel.js').ExportPageContentv4} pageInfo + * @param {import('../Shared/Model/ExportModel.js').ExportPageContentv4} pageInfo * @returns {ClientPageInfo} */ function simplifyPageForClient(pageInfo) { @@ -813,7 +813,7 @@ class DataImportExport extends CoreBase { ) /** - * @param {import('./Model/ExportModel.js').ExportPageContentv4} pageInfo + * @param {import('../Shared/Model/ExportModel.js').ExportPageContentv4} pageInfo * @param {number} topage * @param {InstanceAppliedRemappings} instanceIdMap * @returns {void} @@ -1031,7 +1031,7 @@ class DataImportExport extends CoreBase { } /** - * @param {import('./Model/ExportModel.js').ExportInstancesv4 | undefined} instances + * @param {import('../Shared/Model/ExportModel.js').ExportInstancesv4 | undefined} instances * @param {InstanceRemappings} instanceRemapping * @returns {InstanceAppliedRemappings} */ @@ -1092,9 +1092,9 @@ class DataImportExport extends CoreBase { } /** - * @param {Readonly} control + * @param {Readonly} control * @param {InstanceAppliedRemappings} instanceIdMap - * @returns {import('./Model/TriggerModel.js').TriggerModel} + * @returns {import('../Shared/Model/TriggerModel.js').TriggerModel} */ #fixupTriggerControl(control, instanceIdMap) { // Future: this does not feel durable @@ -1112,7 +1112,7 @@ class DataImportExport extends CoreBase { } } - /** @type {import('./Model/TriggerModel.js').TriggerModel} */ + /** @type {import('../Shared/Model/TriggerModel.js').TriggerModel} */ const result = { type: 'trigger', options: cloneDeep(control.options), @@ -1122,7 +1122,7 @@ class DataImportExport extends CoreBase { } if (control.condition) { - /** @type {import('./Model/FeedbackModel.js').FeedbackInstance[]} */ + /** @type {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} */ const newFeedbacks = [] for (const feedback of control.condition) { const instanceInfo = instanceIdMap[feedback?.instance_id] @@ -1137,11 +1137,11 @@ class DataImportExport extends CoreBase { result.condition = newFeedbacks } - /** @type {import('./Model/ActionModel.js').ActionInstance[]} */ + /** @type {import('../Shared/Model/ActionModel.js').ActionInstance[]} */ const allActions = [] if (control.action_sets) { for (const [setId, action_set] of Object.entries(control.action_sets)) { - /** @type {import('./Model/ActionModel.js').ActionInstance[]} */ + /** @type {import('../Shared/Model/ActionModel.js').ActionInstance[]} */ const newActions = [] for (const action of action_set) { const instanceInfo = instanceIdMap[action?.instance] @@ -1174,9 +1174,9 @@ class DataImportExport extends CoreBase { } /** - * @param {import('./Model/ExportModel.js').ExportControlv4 } control + * @param {import('../Shared/Model/ExportModel.js').ExportControlv4 } control * @param {InstanceAppliedRemappings} instanceIdMap - * @returns {import('./Model/ButtonModel.js').SomeButtonModel} + * @returns {import('../Shared/Model/ButtonModel.js').SomeButtonModel} */ #fixupControl(control, instanceIdMap) { // Future: this does not feel durable @@ -1200,7 +1200,7 @@ class DataImportExport extends CoreBase { } } - /** @type {import('./Model/ButtonModel.js').NormalButtonModel} */ + /** @type {import('../Shared/Model/ButtonModel.js').NormalButtonModel} */ const result = { type: 'button', options: cloneDeep(control.options), @@ -1210,7 +1210,7 @@ class DataImportExport extends CoreBase { } if (control.feedbacks) { - /** @type {import('./Model/FeedbackModel.js').FeedbackInstance[]} */ + /** @type {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} */ const newFeedbacks = [] for (const feedback of control.feedbacks) { const instanceInfo = instanceIdMap[feedback?.instance_id] @@ -1225,11 +1225,11 @@ class DataImportExport extends CoreBase { result.feedbacks = newFeedbacks } - /** @type {import('./Model/ActionModel.js').ActionInstance[]} */ + /** @type {import('../Shared/Model/ActionModel.js').ActionInstance[]} */ const allActions = [] if (control.steps) { for (const [stepId, step] of Object.entries(control.steps)) { - /** @type {import('./Model/ActionModel.js').ActionSetsModel} */ + /** @type {import('../Shared/Model/ActionModel.js').ActionSetsModel} */ const newStepSets = {} result.steps[stepId] = { action_sets: newStepSets, @@ -1237,7 +1237,7 @@ class DataImportExport extends CoreBase { } for (const [setId, action_set] of Object.entries(step.action_sets)) { - /** @type {import('./Model/ActionModel.js').ActionInstance[]} */ + /** @type {import('../Shared/Model/ActionModel.js').ActionInstance[]} */ const newActions = [] for (const action of action_set) { const instanceInfo = instanceIdMap[action?.instance] @@ -1273,10 +1273,10 @@ class DataImportExport extends CoreBase { /** * Visit any references within the given control * @param {import('../Internal/Types.js').InternalVisitor} visitor Visitor to be used - * @param {import('./Model/StyleModel.js').ButtonStyleProperties | undefined} style Style object of the control (if any) - * @param {import('./Model/ActionModel.js').ActionInstance[]} actions Array of actions belonging to the control - * @param {import('./Model/FeedbackModel.js').FeedbackInstance[]} feedbacks Array of feedbacks belonging to the control - * @param {import('./Model/EventModel.js').EventInstance[] | undefined} events Array of events belonging to the control + * @param {import('../Shared/Model/StyleModel.js').ButtonStyleProperties | undefined} style Style object of the control (if any) + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions Array of actions belonging to the control + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks Array of feedbacks belonging to the control + * @param {import('../Shared/Model/EventModel.js').EventInstance[] | undefined} events Array of events belonging to the control */ visitControlReferences(visitor, style, actions, feedbacks, events) { // Update the base style @@ -1313,10 +1313,10 @@ class DataImportExport extends CoreBase { /** * Fixup any references within the given control * @param {FixupReferencesUpdateMaps} updateMaps Description of instance ids and labels to remap - * @param {import('./Model/StyleModel.js').ButtonStyleProperties | undefined} style Style object of the control (if any) - * @param {import('./Model/ActionModel.js').ActionInstance[]} actions Array of actions belonging to the control - * @param {import('./Model/FeedbackModel.js').FeedbackInstance[]} feedbacks Array of feedbacks belonging to the control - * @param {import('./Model/EventModel.js').EventInstance[] | undefined} events Array of events belonging to the control + * @param {import('../Shared/Model/StyleModel.js').ButtonStyleProperties | undefined} style Style object of the control (if any) + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions Array of actions belonging to the control + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks Array of feedbacks belonging to the control + * @param {import('../Shared/Model/EventModel.js').EventInstance[] | undefined} events Array of events belonging to the control * @param {boolean} recheckChangedFeedbacks Whether to recheck the feedbacks that were modified * @returns {boolean} Whether any changes were made */ @@ -1339,55 +1339,20 @@ export default DataImportExport /** * @typedef {Record} InstanceRemappings * @typedef {Record} InstanceAppliedRemappings - * + * * @typedef {{ * connectionLabels?: Record * connectionIds?: Record * }} FixupReferencesUpdateMaps - * - * @typedef {{ - * name: string - * gridSize: import('./Model/ExportModel.js').ExportGridSize - * }} ClientPageInfo - * - * @typedef {{ - * type: 'page' | 'full' | 'trigger_list' - * instances: Record - * controls: boolean - * customVariables: boolean - * surfaces: boolean - * triggers: boolean | Record - * oldPageNumber?: number - * page?: ClientPageInfo - * pages?: Record - * }} ClientImportObject - * - * @typedef {{ - * buttons: boolean - * customVariables: boolean - * surfaces: boolean - * triggers: boolean - * }} ClientImportSelection - * - * @typedef {{ - * buttons: boolean - * triggers: boolean - * customVariables: boolean - * connections: boolean - * surfaces: boolean -* }} ClientExportSelection - - * @typedef {{ - * buttons: boolean - * connections: boolean - * surfaces: boolean - * triggers: boolean - * customVariables: boolean - * userconfig: boolean - * }} ClientResetSelection - * + * + * @typedef {import('../Shared/Model/ImportExport.js').ClientPageInfo} ClientPageInfo + * @typedef {import('../Shared/Model/ImportExport.js').ClientImportObject} ClientImportObject + * @typedef {import('../Shared/Model/ImportExport.js').ClientImportSelection} ClientImportSelection + * @typedef {import('../Shared/Model/ImportExport.js').ClientExportSelection} ClientExportSelection + * @typedef {import('../Shared/Model/ImportExport.js').ClientResetSelection} ClientResetSelection + * * @typedef {{ - * object: import('./Model/ExportModel.js').ExportFullv4 | import('./Model/ExportModel.js').ExportPageModelv4 + * object: import('../Shared/Model/ExportModel.js').ExportFullv4 | import('../Shared/Model/ExportModel.js').ExportPageModelv4 * timeout: null * }} ClientPendingImport */ diff --git a/lib/Data/Upgrade.js b/lib/Data/Upgrade.js index 0a4ca139bf..2cdd870be0 100644 --- a/lib/Data/Upgrade.js +++ b/lib/Data/Upgrade.js @@ -65,7 +65,7 @@ export function upgradeStartup(db) { /** * Upgrade an exported page or full configuration to the latest format * @param {any} obj - * @returns {import('./Model/ExportModel.js').SomeExportv4} + * @returns {import('../Shared/Model/ExportModel.js').SomeExportv4} */ export function upgradeImport(obj) { const currentVersion = obj.version || 1 diff --git a/lib/Data/Upgrades/v3tov4.js b/lib/Data/Upgrades/v3tov4.js index e20c9ebb11..1644c1fc65 100644 --- a/lib/Data/Upgrades/v3tov4.js +++ b/lib/Data/Upgrades/v3tov4.js @@ -77,11 +77,11 @@ function ParseBankControlId(controlId) { } /** - * @param {{ triggers?: import('../Model/TriggerModel.js').TriggerModel[] | Record; }} obj + * @param {{ triggers?: import('../../Shared/Model/TriggerModel.js').TriggerModel[] | Record; }} obj */ function ensureTriggersAreObject(obj) { if (obj.triggers && Array.isArray(obj.triggers)) { - /** @type {Record} */ + /** @type {Record} */ const triggersObj = {} for (const trigger of obj.triggers) { triggersObj[nanoid()] = trigger diff --git a/lib/Data/UserConfig.js b/lib/Data/UserConfig.js index e029913b75..d3407e05df 100644 --- a/lib/Data/UserConfig.js +++ b/lib/Data/UserConfig.js @@ -26,7 +26,7 @@ import CoreBase from '../Core/Base.js' class DataUserConfig extends CoreBase { /** * The defaults for the user config fields - * @type {import('./Model/UserConfigModel.js').UserConfigModel} + * @type {import('../Shared/Model/UserConfigModel.js').UserConfigModel} * @access public * @static */ @@ -166,7 +166,7 @@ class DataUserConfig extends CoreBase { #populateMissingForExistingDb() { if (!this.db.getIsFirstRun()) { // This is an existing db, so setup the ports to match how it used to be - /** @type {Partial} */ + /** @type {Partial} */ const legacy_config = { tcp_enabled: true, tcp_listen_port: 51234, diff --git a/lib/Graphics/Controller.js b/lib/Graphics/Controller.js index 32ee032f05..0de8c5ffb0 100644 --- a/lib/Graphics/Controller.js +++ b/lib/Graphics/Controller.js @@ -218,7 +218,7 @@ class GraphicsController extends CoreBase { * @returns {Promise} */ async drawPreview(buttonStyle) { - /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel} */ + /** @type {import('../Shared/Model/StyleModel.js').DrawStyleModel} */ const drawStyle = { ...buttonStyle, diff --git a/lib/Graphics/ImageResult.js b/lib/Graphics/ImageResult.js index 292a9f10a1..d8ea55e457 100644 --- a/lib/Graphics/ImageResult.js +++ b/lib/Graphics/ImageResult.js @@ -1,5 +1,5 @@ /** - * @typedef {import("../Data/Model/StyleModel.js").DrawStyleButtonModel | 'pagenum' | 'pageup' | 'pagedown'} ImageResultStyle + * @typedef {import("../Shared/Model/StyleModel.js").DrawStyleButtonModel | 'pagenum' | 'pageup' | 'pagedown'} ImageResultStyle */ export class ImageResult { diff --git a/lib/Graphics/Renderer.js b/lib/Graphics/Renderer.js index 353c42aad9..169544edb8 100644 --- a/lib/Graphics/Renderer.js +++ b/lib/Graphics/Renderer.js @@ -68,7 +68,7 @@ export default class GraphicsRenderer { /** * Draw the image for a button * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw + * @param {import('../Shared/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public @@ -91,8 +91,8 @@ export default class GraphicsRenderer { * @param {number} width * @param {number} height * @param {string} dataUrl - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle + * @param {import('../Shared/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style + * @param {import('../Shared/Model/StyleModel.js').DrawStyleModel} drawStyle * @returns */ static wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, drawStyle) { @@ -104,18 +104,18 @@ export default class GraphicsRenderer { /** * Draw the image for a btuton * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw + * @param {import('../Shared/Model/StyleModel.js').DrawStyleModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @param {string | undefined} pagename * @access public - * @returns {Promise<{ buffer: Buffer, width: number, height: number, dataUrl: string, draw_style: import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object + * @returns {Promise<{ buffer: Buffer, width: number, height: number, dataUrl: string, draw_style: import('../Shared/Model/StyleModel.js').DrawStyleModel['style'] | undefined}>} Image render object */ static async drawButtonImageUnwrapped(options, drawStyle, location, pagename) { // console.log('starting drawButtonImage '+ performance.now()) // console.time('drawButtonImage') const img = new Image(72, 72, 4) - /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel['style'] | undefined} */ + /** @type {import('../Shared/Model/StyleModel.js').DrawStyleModel['style'] | undefined} */ let draw_style = undefined // special button types @@ -194,7 +194,7 @@ export default class GraphicsRenderer { * Draw the main button * @param {Image} img Image to draw to * @param {import('./Controller.js').GraphicsOptions} options - * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw + * @param {import('../Shared/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @access private */ @@ -290,7 +290,7 @@ export default class GraphicsRenderer { * Draw the topbar onto an image for a button * @param {Image} img Image to draw to * @param {boolean} show_topbar - * @param {import('../Data/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw + * @param {import('../Shared/Model/StyleModel.js').DrawStyleButtonModel} drawStyle The style to draw * @param {import('../Resources/Util.js').ControlLocation | undefined} location * @access private */ diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index dd02396b9b..88b5b9a0e1 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -40,13 +40,7 @@ const InstancesRoom = 'instances' * }} ConnectionConfig */ /** - * @typedef {{ - * label: string - * instance_type: string - * enabled: boolean - * sortOrder: number - * hasRecordActionsHandler: boolean - * }} ClientConnectionConfig + * @typedef {import('../Shared/Model/Common.js').ClientConnectionConfig} ClientConnectionConfig */ /** * @typedef {{ @@ -427,7 +421,7 @@ class Instance extends CoreBase { * @param {string} instanceId * @param {boolean} minimal * @param {boolean} clone - * @returns {import('../Data/Model/ExportModel.js').ExportInstanceFullv4 | import('../Data/Model/ExportModel.js').ExportInstanceMinimalv4} + * @returns {import('../Shared/Model/ExportModel.js').ExportInstanceFullv4 | import('../Shared/Model/ExportModel.js').ExportInstanceMinimalv4} */ exportInstance(instanceId, minimal = false, clone = true) { const rawObj = this.store.db[instanceId] diff --git a/lib/Instance/CustomVariable.js b/lib/Instance/CustomVariable.js index 602ab91f70..ff8bec099d 100644 --- a/lib/Instance/CustomVariable.js +++ b/lib/Instance/CustomVariable.js @@ -43,7 +43,7 @@ export default class InstanceCustomVariable { /** * Custom variable definitions - * @type {import('../Data/Model/CustomVariableModel.js').CustomVariablesModel} + * @type {import('../Shared/Model/CustomVariableModel.js').CustomVariablesModel} * @access private */ #custom_variables @@ -177,7 +177,7 @@ export default class InstanceCustomVariable { /** * Get all the current custom variable definitions - * @returns {import('../Data/Model/CustomVariableModel.js').CustomVariablesModel} + * @returns {import('../Shared/Model/CustomVariableModel.js').CustomVariablesModel} * @access public */ getDefinitions() { @@ -210,7 +210,7 @@ export default class InstanceCustomVariable { /** * Replace all of the current custom variables with new ones - * @param {import('../Data/Model/CustomVariableModel.js').CustomVariablesModel} custom_variables + * @param {import('../Shared/Model/CustomVariableModel.js').CustomVariablesModel} custom_variables * @access public */ replaceDefinitions(custom_variables) { diff --git a/lib/Instance/Definitions.js b/lib/Instance/Definitions.js index d4f2dbb534..d7782ba1f8 100644 --- a/lib/Instance/Definitions.js +++ b/lib/Instance/Definitions.js @@ -203,12 +203,12 @@ class InstanceDefinitions extends CoreBase { /** * * @param {string} eventType - * @returns {import('../Data/Model/EventModel.js').EventInstance | null} + * @returns {import('../Shared/Model/EventModel.js').EventInstance | null} */ createEventItem(eventType) { const definition = EventDefinitions[eventType] if (definition) { - /** @type {import('../Data/Model/EventModel.js').EventInstance} */ + /** @type {import('../Shared/Model/EventModel.js').EventInstance} */ const event = { id: nanoid(), type: eventType, @@ -289,7 +289,7 @@ class InstanceDefinitions extends CoreBase { const definition = this.#presetDefinitions[connectionId]?.[presetId] if (!definition) return false - /** @type {import('../Data/Model/ButtonModel.js').NormalButtonModel} */ + /** @type {import('../Shared/Model/ButtonModel.js').NormalButtonModel} */ const result = { type: 'button', options: { @@ -311,7 +311,7 @@ class InstanceDefinitions extends CoreBase { } if (definition.steps) { for (let i = 0; i < definition.steps.length; i++) { - /** @type {import('../Data/Model/ButtonModel.js').NormalButtonSteps[0]} */ + /** @type {import('../Shared/Model/ButtonModel.js').NormalButtonSteps[0]} */ const newStep = { action_sets: {}, options: cloneDeep(definition.steps[i].options) ?? cloneDeep(ControlButtonNormal.DefaultStepOptions), @@ -319,7 +319,7 @@ class InstanceDefinitions extends CoreBase { result.steps[i] = newStep for (const [set, actions_set] of Object.entries(definition.steps[i].action_sets)) { - newStep.action_sets[set] = actions_set.map((action) => ({ + newStep.action_sets[set] = actions_set.map((/** @type {PresetActionInstance} */ action) => ({ id: nanoid(), instance: connectionId, action: action.action, @@ -558,62 +558,16 @@ class InstanceDefinitions extends CoreBase { export default InstanceDefinitions /** - * @typedef {{ - * label: string - * description: string | undefined - * options: import('@companion-module/base/dist/host-api/api.js').EncodeIsVisible[] - * hasLearn: boolean - * }} ActionDefinition + * @typedef {import('../Shared/Model/Options.js').ActionDefinition} ActionDefinition + * @typedef {import('../Shared/Model/Options.js').FeedbackDefinition} FeedbackDefinition */ /** - * @typedef {{ - * label: string - * description: string | undefined - * options: import('@companion-module/base/dist/host-api/api.js').EncodeIsVisible[] - * type: 'advanced' | 'boolean' - * style: Partial | undefined - * hasLearn: boolean - * showInvert: boolean - * }} FeedbackDefinition - */ - -/** - * @typedef {{ - * type: string - * options: import('@companion-module/base').CompanionOptionValues - * style: Partial | undefined - * isInverted?: boolean - * }} PresetFeedbackInstance - * - * @typedef {{ - * action: string - * options: import('@companion-module/base').CompanionOptionValues - * delay: number - * }} PresetActionInstance - * - * @typedef {{ - * down: PresetActionInstance[] - * up: PresetActionInstance[] - * [delay: number]: PresetActionInstance[] - * }} PresetActionSets - * - * @typedef {{ - * options?: import('../Data/Model/ActionModel.js').ActionStepOptions - * action_sets: PresetActionSets - * }} PresetActionSteps - * - * @typedef {{ - * id: string - * name: string - * category: string - * type: 'button' - * style: import('@companion-module/base').CompanionButtonStyleProps - * previewStyle: import('@companion-module/base').CompanionButtonStyleProps | undefined - * options: import('@companion-module/base').CompanionButtonPresetOptions | undefined - * feedbacks: PresetFeedbackInstance[] - * steps: PresetActionSteps[] - * }} PresetDefinition + * @typedef {import('../Shared/Model/Presets.js').PresetFeedbackInstance} PresetFeedbackInstance + * @typedef {import('../Shared/Model/Presets.js').PresetActionInstance} PresetActionInstance + * @typedef {import('../Shared/Model/Presets.js').PresetActionSets} PresetActionSets + * @typedef {import('../Shared/Model/Presets.js').PresetActionSteps} PresetActionSteps + * @typedef {import('../Shared/Model/Presets.js').PresetDefinition} PresetDefinition */ /** @@ -623,9 +577,5 @@ export default InstanceDefinitions */ /** - * @typedef {{ - * id: string - * label: string - * category: string - * }} UIPresetDefinition + * @typedef {import('../Shared/Model/Presets.js').UIPresetDefinition} UIPresetDefinition */ diff --git a/lib/Instance/Modules.js b/lib/Instance/Modules.js index 2b3ab84d00..cf6700bcc9 100644 --- a/lib/Instance/Modules.js +++ b/lib/Instance/Modules.js @@ -38,18 +38,7 @@ const ModulesRoom = 'modules' */ /** - * @typedef {{ - * id: string - * name: string - * version: string - * hasHelp: boolean - * bugUrl: string - * shortname: string - * manufacturer: string - * products: string[] - * keywords: string[] - * isLegacy?: boolean - * }} ModuleDisplayInfo + * @typedef {import('../Shared/Model/Common.js').ModuleDisplayInfo} ModuleDisplayInfo */ class InstanceModules extends CoreBase { diff --git a/lib/Instance/Status.js b/lib/Instance/Status.js index 6f6c455924..949f6e7b1e 100644 --- a/lib/Instance/Status.js +++ b/lib/Instance/Status.js @@ -4,11 +4,7 @@ import LogController from '../Log/Controller.js' import { EventEmitter } from 'events' /** - * @typedef {{ - * category: string | null - * level: string | null - * message: string | undefined - * }} StatusEntry + * @typedef {import('../Shared/Model/Common.js').ConnectionStatusEntry} StatusEntry */ export default class Status extends EventEmitter { diff --git a/lib/Instance/Variable.js b/lib/Instance/Variable.js index 7ce2582f3c..616a3dd72a 100644 --- a/lib/Instance/Variable.js +++ b/lib/Instance/Variable.js @@ -31,7 +31,6 @@ const VariableDefinitionsRoom = 'variable-definitions' * @typedef {Record | undefined>} VariableValueData * @typedef {Record} VariablesCache * @typedef {{ text: string, variableIds: string[] }} ParseVariablesResult - * @typedef {{ label: string }} VariableDefinition */ // Export for unit tests @@ -109,7 +108,7 @@ class InstanceVariable extends CoreBase { #variableValues = {} /** - * @type {Record | undefined>} + * @type {import('../Shared/Model/Variables.js').AllVariableDefinitions} */ #variableDefinitions = {} @@ -322,11 +321,11 @@ class InstanceVariable extends CoreBase { setVariableDefinitions(instance_label, variables) { this.logger.silly('got instance variable definitions for ' + instance_label) - /** @type {Record} */ + /** @type {import('../Shared/Model/Variables.js').ModuleVariableDefinitions} */ const variablesObj = {} for (const variable of variables || []) { // Prune out the name - /** @type {VariableDefinition} */ + /** @type {import('../Shared/Model/Variables.js').VariableDefinition} */ const newVarObj = { label: variable.label, } diff --git a/lib/Instance/Wrapper.js b/lib/Instance/Wrapper.js index 44e4967379..a144975c6b 100644 --- a/lib/Instance/Wrapper.js +++ b/lib/Instance/Wrapper.js @@ -296,7 +296,7 @@ class SocketEventsHandler { /** * Inform the child instance class about an updated feedback - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @param {string} controlId * @returns {Promise} */ @@ -332,7 +332,7 @@ class SocketEventsHandler { /** * - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @param {string} controlId * @returns {Promise} */ @@ -371,7 +371,7 @@ class SocketEventsHandler { /** * Inform the child instance class about an feedback that has been deleted - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} oldFeedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} oldFeedback * @returns {Promise} */ async feedbackDelete(oldFeedback) { @@ -387,7 +387,7 @@ class SocketEventsHandler { /** * Inform the child instance class about an updated action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} controlId * @returns {Promise} */ @@ -414,7 +414,7 @@ class SocketEventsHandler { } /** * Inform the child instance class about an action that has been deleted - * @param {import('../Data/Model/ActionModel.js').ActionInstance} oldAction + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} oldAction * @returns {Promise} */ async actionDelete(oldAction) { @@ -430,7 +430,7 @@ class SocketEventsHandler { /** * - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} controlId * @returns {Promise} */ @@ -461,7 +461,7 @@ class SocketEventsHandler { /** * Tell the child instance class to execute an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {RunActionExtras} extras * @returns {Promise} */ @@ -608,6 +608,7 @@ class SocketEventsHandler { actions[rawAction.id] = { label: rawAction.name, description: rawAction.description, + // @ts-expect-error @companion-module-base exposes these through a mapping that loses the differentiation between types options: rawAction.options || [], hasLearn: !!rawAction.hasLearn, } @@ -629,6 +630,7 @@ class SocketEventsHandler { feedbacks[rawFeedback.id] = { label: rawFeedback.name, description: rawFeedback.description, + // @ts-expect-error @companion-module-base exposes these through a mapping that loses the differentiation between types options: rawFeedback.options || [], type: rawFeedback.type, style: rawFeedback.defaultStyle, diff --git a/lib/Internal/ActionRecorder.js b/lib/Internal/ActionRecorder.js index dced367ce0..4bb1b8c7ef 100644 --- a/lib/Internal/ActionRecorder.js +++ b/lib/Internal/ActionRecorder.js @@ -82,6 +82,7 @@ export default class ActionRecorder { return { action_recorder_set_recording: { label: 'Action Recorder: Set recording', + description: undefined, options: [ { type: 'dropdown', @@ -98,6 +99,7 @@ export default class ActionRecorder { }, action_recorder_set_connections: { label: 'Action Recorder: Set connections', + description: undefined, options: [ { type: 'dropdown', @@ -124,6 +126,7 @@ export default class ActionRecorder { }, action_recorder_save_to_button: { label: 'Action Recorder: Finish recording and save to button', + description: undefined, options: [ { type: 'textinput', @@ -167,6 +170,7 @@ export default class ActionRecorder { }, action_recorder_discard_actions: { label: 'Action Recorder: Discard actions', + description: undefined, options: [], }, } @@ -174,7 +178,7 @@ export default class ActionRecorder { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} extras * @returns {boolean} Whether the action was handled */ @@ -353,8 +357,8 @@ export default class ActionRecorder { /** * * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks */ visitReferences(visitor, actions, feedbacks) { for (const action of actions) { diff --git a/lib/Internal/Controller.js b/lib/Internal/Controller.js index 32ec3ebdc1..f2724849d9 100644 --- a/lib/Internal/Controller.js +++ b/lib/Internal/Controller.js @@ -108,9 +108,9 @@ export default class InternalController extends CoreBase { /** * Perform an upgrade for an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} controlId - * @returns {import('../Data/Model/ActionModel.js').ActionInstance | undefined} Updated action if any changes were made + * @returns {import('../Shared/Model/ActionModel.js').ActionInstance | undefined} Updated action if any changes were made */ actionUpgrade(action, controlId) { for (const fragment of this.fragments) { @@ -134,9 +134,9 @@ export default class InternalController extends CoreBase { } /** * Perform an upgrade for a feedback - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @param {string} controlId - * @returns {import('../Data/Model/FeedbackModel.js').FeedbackInstance | undefined} Updated feedback if any changes were made + * @returns {import('../Shared/Model/FeedbackModel.js').FeedbackInstance | undefined} Updated feedback if any changes were made */ feedbackUpgrade(feedback, controlId) { for (const fragment of this.fragments) { @@ -161,7 +161,7 @@ export default class InternalController extends CoreBase { /** * A feedback has changed, and state should be updated - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @param {string} controlId * @returns {void} */ @@ -190,7 +190,7 @@ export default class InternalController extends CoreBase { } /** * A feedback has been deleted - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @returns {void} */ feedbackDelete(feedback) { @@ -242,8 +242,8 @@ export default class InternalController extends CoreBase { /** * Visit any references in some inactive internal actions and feedbacks * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks */ visitReferences(visitor, actions, feedbacks) { const internalActions = actions.filter((a) => a.instance === 'internal') @@ -258,7 +258,7 @@ export default class InternalController extends CoreBase { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} extras * @returns {void} */ diff --git a/lib/Internal/Controls.js b/lib/Internal/Controls.js index 9a0f2ed595..2724a4fdf2 100644 --- a/lib/Internal/Controls.js +++ b/lib/Internal/Controls.js @@ -16,7 +16,7 @@ */ import { cloneDeep } from 'lodash-es' -import { SplitVariableId, rgb } from '../Resources/Util.js' +import { SplitVariableId, rgb, serializeIsVisibleFnSingle } from '../Resources/Util.js' import { oldBankIndexToXY, ParseControlId } from '../Shared/ControlId.js' import { ButtonStyleProperties } from '../Shared/Style.js' import debounceFn from 'debounce-fn' @@ -39,18 +39,18 @@ const CHOICES_PAGE_WITH_VARIABLES = [ id: 'page_from_variable', default: false, }, - { + serializeIsVisibleFnSingle({ ...CHOICES_PAGE, isVisible: (options) => !options.page_from_variable, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Page (expression)', id: 'page_variable', default: '1', isVisible: (options) => !!options.page_from_variable, useVariables: true, - }, + }), ] /** @type {import('./Types.js').InternalActionInputField[]} */ @@ -66,7 +66,7 @@ const CHOICES_DYNAMIC_LOCATION = [ { id: 'expression', label: 'From expression' }, ], }, - { + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Location (text with variables)', tooltip: 'eg 1/0/0 or $(this:page)/$(this:row)/$(this:column)', @@ -76,8 +76,8 @@ const CHOICES_DYNAMIC_LOCATION = [ useVariables: true, // @ts-ignore useInternalLocationVariables: true, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Location (expression)', tooltip: 'eg `1/0/0` or `${$(this:page) + 1}/${$(this:row)}/${$(this:column)}`', @@ -87,7 +87,7 @@ const CHOICES_DYNAMIC_LOCATION = [ useVariables: true, // @ts-ignore useInternalLocationVariables: true, - }, + }), ] /** @type {import('./Types.js').InternalActionInputField[]} */ @@ -98,7 +98,7 @@ const CHOICES_STEP_WITH_VARIABLES = [ id: 'step_from_expression', default: false, }, - { + serializeIsVisibleFnSingle({ type: 'number', label: 'Step', tooltip: 'Which Step?', @@ -107,15 +107,15 @@ const CHOICES_STEP_WITH_VARIABLES = [ min: 1, max: Number.MAX_SAFE_INTEGER, isVisible: (options) => !options.step_from_expression, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Step (expression)', id: 'step_expression', default: '1', isVisible: (options) => !!options.step_from_expression, useVariables: true, - }, + }), ] const ButtonStylePropertiesExt = [ @@ -279,6 +279,7 @@ export default class Controls { return { button_pressrelease: { label: 'Button: Trigger press and release', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -292,6 +293,7 @@ export default class Controls { }, button_pressrelease_if_expression: { label: 'Button: Trigger press and release if expression is true', + description: undefined, showButtonPreview: true, options: [ { @@ -314,6 +316,7 @@ export default class Controls { }, button_pressrelease_condition: { label: 'Button: Trigger press and release if variable meets condition', + description: undefined, showButtonPreview: true, options: [ { @@ -347,6 +350,7 @@ export default class Controls { button_press: { label: 'Button: Trigger press', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -360,6 +364,7 @@ export default class Controls { }, button_release: { label: 'Button: Trigger release', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -387,6 +392,7 @@ export default class Controls { button_text: { label: 'Button: Set text', + description: undefined, showButtonPreview: true, options: [ { @@ -400,6 +406,7 @@ export default class Controls { }, textcolor: { label: 'Button: Set text color', + description: undefined, showButtonPreview: true, options: [ { @@ -413,6 +420,7 @@ export default class Controls { }, bgcolor: { label: 'Button: Set background color', + description: undefined, showButtonPreview: true, options: [ { @@ -427,6 +435,7 @@ export default class Controls { panic_bank: { label: 'Actions: Abort delayed actions on a button', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -440,6 +449,7 @@ export default class Controls { }, panic_page: { label: 'Actions: Abort all delayed actions on a page', + description: undefined, options: [ ...CHOICES_PAGE_WITH_VARIABLES, { @@ -452,6 +462,7 @@ export default class Controls { }, panic_trigger: { label: 'Actions: Abort delayed actions on a trigger', + description: undefined, options: [ { type: 'internal:trigger', @@ -464,16 +475,19 @@ export default class Controls { }, panic: { label: 'Actions: Abort all delayed actions on buttons and triggers', + description: undefined, options: [], }, bank_current_step: { label: 'Button: Set current step', + description: undefined, showButtonPreview: true, options: [...CHOICES_DYNAMIC_LOCATION, ...CHOICES_STEP_WITH_VARIABLES], }, bank_current_step_condition: { label: 'Button: Set current step if variable meets condition', + description: undefined, showButtonPreview: true, options: [ { @@ -507,6 +521,7 @@ export default class Controls { }, bank_current_step_if_expression: { label: 'Button: Set current step if expression is true', + description: undefined, showButtonPreview: true, options: [ { @@ -522,6 +537,7 @@ export default class Controls { }, bank_current_step_delta: { label: 'Button: Skip step', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -602,9 +618,9 @@ export default class Controls { } /** - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @param {string} _controlId - * @returns {import('../Data/Model/FeedbackModel.js').FeedbackInstance | void} + * @returns {import('../Shared/Model/FeedbackModel.js').FeedbackInstance | void} */ feedbackUpgrade(feedback, _controlId) { let changed = false @@ -739,9 +755,9 @@ export default class Controls { /** * Perform an upgrade for an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} _controlId - * @returns {import('../Data/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made + * @returns {import('../Shared/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made */ actionUpgrade(action, _controlId) { let changed = false @@ -836,7 +852,7 @@ export default class Controls { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} extras * @returns {boolean} Whether the action was handled */ @@ -1026,8 +1042,8 @@ export default class Controls { /** * * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks */ visitReferences(visitor, actions, _feedbacks) { for (const action of actions) { diff --git a/lib/Internal/CustomVariables.js b/lib/Internal/CustomVariables.js index c51d880710..dbc7d6cfbe 100644 --- a/lib/Internal/CustomVariables.js +++ b/lib/Internal/CustomVariables.js @@ -50,6 +50,7 @@ export default class CustomVariables { return { custom_variable_set_value: { label: 'Custom Variable: Set raw value', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -66,6 +67,7 @@ export default class CustomVariables { }, custom_variable_create_value: { label: 'Custom Variable: Set or Create raw value if not exists', + description: undefined, options: [ { type: 'textinput', @@ -82,6 +84,7 @@ export default class CustomVariables { }, custom_variable_set_expression: { label: 'Custom Variable: Set with expression', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -99,6 +102,7 @@ export default class CustomVariables { }, custom_variable_store_variable: { label: 'Custom Variable: Store variable value', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -110,12 +114,12 @@ export default class CustomVariables { id: 'variable', label: 'Variable to store value from', tooltip: 'What variable to store in the custom variable?', - default: 'internal:time_hms', }, ], }, custom_variable_set_via_jsonpath: { label: 'Custom Variable: Set from a stored JSONresult via a JSONpath expression', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -138,6 +142,7 @@ export default class CustomVariables { custom_variable_reset_to_default: { label: 'Reset custom variable to startup value', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -148,6 +153,7 @@ export default class CustomVariables { }, custom_variable_sync_to_default: { label: 'Write custom variable current value to startup value', + description: undefined, options: [ { type: 'internal:custom_variable', @@ -161,9 +167,9 @@ export default class CustomVariables { /** * Perform an upgrade for an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} _controlId - * @returns {import('../Data/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made + * @returns {import('../Shared/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made */ actionUpgrade(action, _controlId) { const variableRegex = /^\$\(([^:$)]+):([^)$]+)\)$/ @@ -271,7 +277,7 @@ export default class CustomVariables { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} _extras * @returns {boolean} Whether the action was handled */ @@ -349,8 +355,8 @@ export default class CustomVariables { /** * * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks */ visitReferences(visitor, actions, _feedbacks) { for (const action of actions) { diff --git a/lib/Internal/Instance.js b/lib/Internal/Instance.js index 1cd3c5e712..4e5a6b79b8 100644 --- a/lib/Internal/Instance.js +++ b/lib/Internal/Instance.js @@ -124,11 +124,13 @@ export default class Instance { return { instance_control: { label: 'Connection: Enable or disable connection', + description: undefined, options: [ { type: 'internal:instance_id', label: 'Connection', id: 'instance_id', + multiple: false, }, { type: 'dropdown', @@ -245,7 +247,7 @@ export default class Instance { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} _extras * @returns {boolean} Whether the action was handled */ @@ -379,8 +381,8 @@ export default class Instance { /** * * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks */ visitReferences(visitor, actions, feedbacks) { for (const action of actions) { diff --git a/lib/Internal/Surface.js b/lib/Internal/Surface.js index 85763933a0..e3a417e280 100644 --- a/lib/Internal/Surface.js +++ b/lib/Internal/Surface.js @@ -17,6 +17,7 @@ import { combineRgb } from '@companion-module/base' import LogController from '../Log/Controller.js' +import { serializeIsVisibleFnSingle } from '../Resources/Util.js' /** @type {import('./Types.js').InternalActionInputField[]} */ const CHOICES_SURFACE_GROUP_WITH_VARIABLES = [ @@ -26,22 +27,22 @@ const CHOICES_SURFACE_GROUP_WITH_VARIABLES = [ id: 'controller_from_variable', default: false, }, - { + serializeIsVisibleFnSingle({ type: 'internal:surface_serial', label: 'Surface / group', id: 'controller', default: 'self', includeSelf: true, isVisible: (options) => !options.controller_from_variable, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Surface / group', id: 'controller_variable', default: 'self', isVisible: (options) => !!options.controller_from_variable, useVariables: true, - }, + }), ] /** @type {import('./Types.js').InternalActionInputField[]} */ @@ -52,7 +53,7 @@ const CHOICES_SURFACE_ID_WITH_VARIABLES = [ id: 'controller_from_variable', default: false, }, - { + serializeIsVisibleFnSingle({ type: 'internal:surface_serial', label: 'Surface / group', id: 'controller', @@ -60,15 +61,15 @@ const CHOICES_SURFACE_ID_WITH_VARIABLES = [ includeSelf: true, useRawSurfaces: true, isVisible: (options) => !options.controller_from_variable, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Surface / group', id: 'controller_variable', default: 'self', isVisible: (options) => !!options.controller_from_variable, useVariables: true, - }, + }), ] /** @type {import('./Types.js').InternalActionInputField[]} */ @@ -79,22 +80,22 @@ const CHOICES_PAGE_WITH_VARIABLES = [ id: 'page_from_variable', default: false, }, - { + serializeIsVisibleFnSingle({ type: 'internal:page', label: 'Page', id: 'page', includeDirection: true, default: 0, isVisible: (options) => !options.page_from_variable, - }, - { + }), + serializeIsVisibleFnSingle({ type: 'textinput', label: 'Page (expression)', id: 'page_variable', default: '1', isVisible: (options) => !!options.page_from_variable, useVariables: true, - }, + }), ] export default class Surface { @@ -217,9 +218,9 @@ export default class Surface { /** * Perform an upgrade for an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} _controlId - * @returns {import('../Data/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made + * @returns {import('../Shared/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made */ actionUpgrade(action, _controlId) { // Upgrade an action. This check is not the safest, but it should be ok @@ -238,6 +239,7 @@ export default class Surface { return { set_brightness: { label: 'Surface: Set to brightness', + description: undefined, options: [ ...CHOICES_SURFACE_ID_WITH_VARIABLES, @@ -256,10 +258,12 @@ export default class Surface { set_page: { label: 'Surface: Set to page', + description: undefined, options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES, ...CHOICES_PAGE_WITH_VARIABLES], }, set_page_byindex: { label: 'Surface: Set by index to page', + description: undefined, options: [ { type: 'number', @@ -277,33 +281,40 @@ export default class Surface { inc_page: { label: 'Surface: Increment page number', + description: undefined, options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, dec_page: { label: 'Surface: Decrement page number', + description: undefined, options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_device: { label: 'Surface: Lockout specified surface immediately.', + description: undefined, options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, unlockout_device: { label: 'Surface: Unlock specified surface immediately.', + description: undefined, options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_all: { label: 'Surface: Lockout all immediately.', + description: undefined, options: [], }, unlockout_all: { label: 'Surface: Unlock all immediately.', + description: undefined, options: [], }, rescan: { label: 'Surface: Rescan USB for devices', + description: undefined, options: [], }, } @@ -311,7 +322,7 @@ export default class Surface { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} extras * @returns {boolean} Whether the action was handled */ @@ -524,8 +535,8 @@ export default class Surface { /** * * @param {import('./Types.js').InternalVisitor} _visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} _actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} _actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} _feedbacks */ visitReferences(_visitor, _actions, _feedbacks) { // actions page_variable handled by generic options visitor diff --git a/lib/Internal/System.js b/lib/Internal/System.js index ed5a24c26e..baf77c7be1 100644 --- a/lib/Internal/System.js +++ b/lib/Internal/System.js @@ -145,6 +145,7 @@ export default class System { const actions = { exec: { label: 'System: Run shell path (local)', + description: undefined, options: [ { type: 'textinput', @@ -174,6 +175,7 @@ export default class System { // Only offer app_restart if there is a handler for the event actions['app_restart'] = { label: 'System: Restart companion', + description: undefined, options: [], } } @@ -183,7 +185,7 @@ export default class System { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} _extras * @returns {boolean} Whether the action was handled */ diff --git a/lib/Internal/Triggers.js b/lib/Internal/Triggers.js index b5528439af..be24ab7d80 100644 --- a/lib/Internal/Triggers.js +++ b/lib/Internal/Triggers.js @@ -61,6 +61,7 @@ export default class Triggers { return { trigger_enabled: { label: 'Trigger: Enable or disable trigger', + description: undefined, options: [ { type: 'internal:trigger', @@ -85,9 +86,9 @@ export default class Triggers { /** * Perform an upgrade for an action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {string} _controlId - * @returns {import('../Data/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made + * @returns {import('../Shared/Model/ActionModel.js').ActionInstance | void} Updated action if any changes were made */ actionUpgrade(action, _controlId) { if (action.action === 'trigger_enabled' && !isNaN(Number(action.options.trigger_id))) { @@ -99,7 +100,7 @@ export default class Triggers { /** * Run a single internal action - * @param {import('../Data/Model/ActionModel.js').ActionInstance} action + * @param {import('../Shared/Model/ActionModel.js').ActionInstance} action * @param {import('../Instance/Wrapper.js').RunActionExtras} _extras * @returns {boolean} Whether the action was handled */ diff --git a/lib/Internal/Types.ts b/lib/Internal/Types.ts index 0023bb2e8d..09635f3795 100644 --- a/lib/Internal/Types.ts +++ b/lib/Internal/Types.ts @@ -2,7 +2,8 @@ import type { ControlLocation } from '../Resources/Util.js' import type { FeedbackInstance } from '../Controls/IControlFragments.js' import type { VisitorReferencesCollector } from '../Util/Visitors/ReferencesCollector.js' import type { VisitorReferencesUpdater } from '../Util/Visitors/ReferencesUpdater.js' -import { SomeCompanionActionInputField } from '@companion-module/base' + +export * from '../Shared/Model/Options.js' export interface FeedbackInstanceExt extends FeedbackInstance { controlId: string @@ -11,54 +12,3 @@ export interface FeedbackInstanceExt extends FeedbackInstance { } export type InternalVisitor = VisitorReferencesCollector | VisitorReferencesUpdater - -export type InternalInputField = ( - | { - type: 'internal:time' - } - | { - type: 'internal:variable' - default: string - } - | { - type: 'internal:custom_variable' - includeNone?: boolean - } - | { - type: 'internal:trigger' - includeSelf?: boolean - default?: string - } - | { - type: 'internal:instance_id' - multiple?: boolean - includeAll?: boolean - filterActionsRecorder?: boolean - default?: string[] - } - | { - type: 'internal:surface_serial' - includeSelf: boolean - default: string - useRawSurfaces?: boolean - } - | { - type: 'internal:page' - includeDirection: boolean - default: number - } - | { - type: 'internal:variable' - } -) & - Omit - -export type InternalActionInputField = SomeCompanionActionInputField | InternalInputField - -export interface InternalActionDefinition { - label: string - description?: string - options: InternalActionInputField[] - hasLearn?: boolean - showButtonPreview?: boolean -} diff --git a/lib/Internal/Variables.js b/lib/Internal/Variables.js index db8e065d4c..9ac458b3d1 100644 --- a/lib/Internal/Variables.js +++ b/lib/Internal/Variables.js @@ -96,7 +96,6 @@ export default class Variables { label: 'Variable', tooltip: 'What variable to act on?', id: 'variable', - default: 'internal:time_hms', }, COMPARISON_OPERATION, { @@ -134,7 +133,6 @@ export default class Variables { label: 'Compare Variable', tooltip: 'What variable to act on?', id: 'variable', - default: 'internal:time_hms', }, COMPARISON_OPERATION, { @@ -142,7 +140,6 @@ export default class Variables { label: 'Against Variable', tooltip: 'What variable to compare with?', id: 'variable2', - default: 'internal:time_hms', }, ], }, @@ -203,7 +200,7 @@ export default class Variables { } /** - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance} feedback + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance} feedback * @returns {void} */ forgetFeedback(feedback) { @@ -238,8 +235,8 @@ export default class Variables { /** * * @param {import('./Types.js').InternalVisitor} visitor - * @param {import('../Data/Model/ActionModel.js').ActionInstance[]} _actions - * @param {import('../Data/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks + * @param {import('../Shared/Model/ActionModel.js').ActionInstance[]} _actions + * @param {import('../Shared/Model/FeedbackModel.js').FeedbackInstance[]} feedbacks */ visitReferences(visitor, _actions, feedbacks) { for (const feedback of feedbacks) { diff --git a/lib/Log/Controller.js b/lib/Log/Controller.js index 04024ee667..35bd505074 100644 --- a/lib/Log/Controller.js +++ b/lib/Log/Controller.js @@ -73,7 +73,7 @@ class LogController { #addBreadcrumb = null /** * The log array - * @type {ClientLogLine[]} + * @type {import('../Shared/Model/LogLine.js').ClientLogLine[]} * @access protected */ #history = [] @@ -197,7 +197,7 @@ class LogController { }) } - /** @type {ClientLogLine[]} */ + /** @type {import('../Shared/Model/LogLine.js').ClientLogLine[]} */ #pendingLines = [] debounceSendLines = debounceFn( () => { @@ -219,7 +219,7 @@ class LogController { * @access private */ #addToHistory(line) { - /** @type {ClientLogLine} */ + /** @type {import('../Shared/Model/LogLine.js').ClientLogLine} */ const uiLine = { time: line.timestamp, source: stripAnsi(line.source), @@ -248,7 +248,7 @@ class LogController { /** * Get all of the log entries * @param {boolean} [clone = false] - true if a clone is needed instead of a reference - * @return {ClientLogLine[]} the log entries + * @return {import('../Shared/Model/LogLine.js').ClientLogLine[]} the log entries * @access public */ getAllLines(clone = false) { @@ -321,12 +321,3 @@ const logger = new LogController() global.logger = logger export default logger - -/** - * @typedef {{ - * time: number - * source: string - * level: string - * message: string - * }} ClientLogLine - */ diff --git a/lib/Page/Controller.js b/lib/Page/Controller.js index 8370e5e0a3..f5fade8898 100644 --- a/lib/Page/Controller.js +++ b/lib/Page/Controller.js @@ -35,7 +35,7 @@ class PageController extends CoreBase { /** * Persisted pages data - * @type {Record} + * @type {Record} * @access private * @readonly */ @@ -85,7 +85,7 @@ class PageController extends CoreBase { /** * Get the entire page table * @param {boolean} [clone = false] - true if a copy should be returned - * @returns {Record} the pages + * @returns {Record} the pages * @access public */ getAll(clone = false) { @@ -230,7 +230,7 @@ class PageController extends CoreBase { * Get a specific page object * @param {number} page - the page id * @param {boolean} [clone = false] - true if a copy should be returned - * @returns {import('../Data/Model/PageModel.js').PageModel | undefined} the requested page + * @returns {import('../Shared/Model/PageModel.js').PageModel | undefined} the requested page * @access public */ getPage(page, clone = false) { @@ -325,7 +325,7 @@ class PageController extends CoreBase { /** * Set/update a page * @param {number} pageNumber - the page id - * @param {Omit} value - the page object containing the name + * @param {Omit} value - the page object containing the name * @param {boolean} [redraw = false] - true if the graphics should invalidate * @access public */ @@ -349,7 +349,7 @@ class PageController extends CoreBase { /** * Commit changes to a page entry * @param {number} pageNumber - * @param {import('../Data/Model/PageModel.js').PageModel} newValue + * @param {import('../Shared/Model/PageModel.js').PageModel} newValue * @param {boolean} redraw */ #commitChanges(pageNumber, newValue, redraw = true) { @@ -370,7 +370,7 @@ class PageController extends CoreBase { /** * Redraw the page number control on the specified page * @param {number} pageNumber - * @param {import('../Data/Model/PageModel.js').PageModel} newValue + * @param {import('../Shared/Model/PageModel.js').PageModel} newValue */ #invalidatePageNumberControls(pageNumber, newValue) { if (newValue?.controls) { diff --git a/lib/Resources/EventDefinitions.js b/lib/Resources/EventDefinitions.js index 15b8ef05b5..5867abfbbe 100644 --- a/lib/Resources/EventDefinitions.js +++ b/lib/Resources/EventDefinitions.js @@ -24,18 +24,7 @@ import os from 'os' */ /** - * @typedef {import('@companion-module/base').SomeCompanionActionInputField | ({ - * type: 'internal:time' - * } | { - * type: 'internal:variable', - * default: string - * }) & Omit} EventInputField - * - * @typedef {{ - * name: string - * options: import('../Internal/Types.js').InternalActionInputField[] - * }} EventDefinition - * + * @typedef {import('../Shared/Model/Common.js').EventDefinition} EventDefinition */ /** @type {Record} */ @@ -165,7 +154,6 @@ export const EventDefinitions = { type: 'internal:variable', id: 'variableId', label: 'Variable to watch', - default: 'internal:time_hms', }, ], }, @@ -191,7 +179,7 @@ switch (os.platform()) { /** * Visit any references within an event * @param {import('../Internal/Types.js').InternalVisitor} visitor Visitor to be used - * @param {import('../Data/Model/EventModel.js').EventInstance} event Events to fixup + * @param {import('../Shared/Model/EventModel.js').EventInstance} event Events to fixup * @returns {void} */ export function visitEventOptions(visitor, event) { diff --git a/lib/Resources/Util.js b/lib/Resources/Util.js index 3d1fe9d83c..6b355e8641 100644 --- a/lib/Resources/Util.js +++ b/lib/Resources/Util.js @@ -1,7 +1,8 @@ +import { serializeIsVisibleFn } from '@companion-module/base/dist/internal/base.js' import imageRs from '@julusian/image-rs' import { colord } from 'colord' -/** @typedef {{ pageNumber: number, row: number, column: number }} ControlLocation */ +/** @typedef {import('../Shared/Model/Common.js').ControlLocation} ControlLocation */ /** * Combine rgba components to a 32bit value @@ -390,3 +391,14 @@ export function pad(str0, ch, len) { return str } + +/** + * + * @template {import('@companion-module/base').CompanionInputFieldBase | import('../Internal/Types.js').CompanionInputFieldBaseExtended} T + * @param {T} field + * @returns {import('../Internal/Types.js').EncodeIsVisible2} + */ +export function serializeIsVisibleFnSingle(field) { + // @ts-ignore + return serializeIsVisibleFn([field])[0] +} diff --git a/lib/Service/BonjourDiscovery.js b/lib/Service/BonjourDiscovery.js index 118e36f7cb..098a0886a0 100644 --- a/lib/Service/BonjourDiscovery.js +++ b/lib/Service/BonjourDiscovery.js @@ -115,7 +115,7 @@ class ServiceBonjourDiscovery extends ServiceBase { /** * @param {string} id * @param {any} svc - * @returns {ClientBonjourService} + * @returns {import('../Shared/Model/Common.js').ClientBonjourService} */ #convertService(id, svc) { return { @@ -245,11 +245,4 @@ export default ServiceBonjourDiscovery * clientIds: Set * }} BonjourBrowserSession * - * @typedef {{ - * subId: string - * fqdn: string - * name: string - * port: number - * addresses: string[] - * }} ClientBonjourService */ diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index 7487f3e3c4..571f472412 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -17,7 +17,7 @@ const LEGACY_NODE_BG_COLOR = 3 /** * Generate ember+ path - * @param {import('../Data/Model/ExportModel.js').ExportGridSize} gridSize + * @param {import('../Shared/Model/UserConfigModel.js').UserConfigGridSize} gridSize * @param {import('../Resources/Util.js').ControlLocation} location * @param {number} node * @returns {string} @@ -137,7 +137,7 @@ class ServiceEmberPlus extends ServiceBase { const controlId = this.page.getControlIdAtOldBankIndex(pageNumber, bank) const control = controlId ? this.controls.getControl(controlId) : undefined - /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + /** @type {import('../Shared/Model/StyleModel.js').DrawStyleModel | null} */ let drawStyle = control?.getDrawStyle() || null if (drawStyle?.style !== 'button') drawStyle = null @@ -214,7 +214,7 @@ class ServiceEmberPlus extends ServiceBase { * @access private */ #getLocationTree() { - /** @type {import('../Data/Model/ExportModel.js').ExportGridSize} */ + /** @type {import('../Shared/Model/UserConfigModel.js').UserConfigGridSize} */ const gridSize = this.userconfig.getKey('gridSize') if (!gridSize) return {} @@ -245,7 +245,7 @@ class ServiceEmberPlus extends ServiceBase { const controlId = this.page.getControlIdAt(location) const control = controlId ? this.controls.getControl(controlId) : undefined - /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + /** @type {import('../Shared/Model/StyleModel.js').DrawStyleModel | null} */ let drawStyle = control?.getDrawStyle() || null if (drawStyle?.style !== 'button') drawStyle = null diff --git a/lib/Shared/Import.js b/lib/Shared/Import.js index bee57ff05f..711bc91e38 100644 --- a/lib/Shared/Import.js +++ b/lib/Shared/Import.js @@ -1,7 +1,11 @@ +/** + * @typedef {{label?: string; sortOrder?: number}} MinimalInstanceInfo + */ + /** * - * @param {[id: string, obj: import("../Data/Model/ExportModel.js").ExportInstanceFullv4 | import("../Data/Model/ExportModel.js").ExportInstanceMinimalv4 | undefined]} param0 - * @param {[id: string, obj: import("../Data/Model/ExportModel.js").ExportInstanceFullv4 | import("../Data/Model/ExportModel.js").ExportInstanceMinimalv4 | undefined]} param1 + * @param {[id: string, obj: MinimalInstanceInfo | undefined]} param0 + * @param {[id: string, obj: MinimalInstanceInfo | undefined]} param1 * @returns number */ export function compareExportedInstances([aId, aObj], [bId, bObj]) { diff --git a/lib/Data/Model/ActionModel.ts b/lib/Shared/Model/ActionModel.ts similarity index 100% rename from lib/Data/Model/ActionModel.ts rename to lib/Shared/Model/ActionModel.ts diff --git a/lib/Shared/Model/ActionRecorderModel.ts b/lib/Shared/Model/ActionRecorderModel.ts new file mode 100644 index 0000000000..50441b4399 --- /dev/null +++ b/lib/Shared/Model/ActionRecorderModel.ts @@ -0,0 +1,21 @@ +export interface RecordSessionInfo { + id: string + connectionIds: string[] + isRunning: boolean + actionDelay: number + actions: RecordActionTmp[] +} + +export interface RecordSessionListInfo { + connectionIds: string[] +} + +// TODO - consolidate +export interface RecordActionTmp { + id: string + instance: string + action: string + delay: number + options: Record + uniquenessId: string | undefined +} diff --git a/lib/Data/Model/ButtonModel.ts b/lib/Shared/Model/ButtonModel.ts similarity index 100% rename from lib/Data/Model/ButtonModel.ts rename to lib/Shared/Model/ButtonModel.ts diff --git a/lib/Shared/Model/Common.ts b/lib/Shared/Model/Common.ts new file mode 100644 index 0000000000..cecbe85c29 --- /dev/null +++ b/lib/Shared/Model/Common.ts @@ -0,0 +1,72 @@ +import type { InternalActionInputField } from './Options.js' + +export interface AppVersionInfo { + appVersion: string + appBuild: string +} +export interface AppUpdateInfo { + message: string + link: string | undefined +} + +export interface ControlLocation { + pageNumber: number + row: number + column: number +} + +export interface EmulatorConfig { + emulator_control_enable: boolean + emulator_prompt_fullscreen: boolean + emulator_columns: number + emulator_rows: number +} + +export interface EmulatorImage { + x: number + y: number + buffer: string | false +} + +export interface ModuleDisplayInfo { + id: string + name: string + version: string + hasHelp: boolean + bugUrl: string + shortname: string + manufacturer: string + products: string[] + keywords: string[] + isLegacy?: boolean +} + +export interface ConnectionStatusEntry { + category: string | null + level: string | null + message: string | undefined +} + +export interface ClientConnectionConfig { + label: string + instance_type: string + enabled: boolean + sortOrder: number + hasRecordActionsHandler: boolean +} + +export interface ClientBonjourService { + subId: string + fqdn: string + name: string + port: number + addresses: string[] +} + +export interface EventDefinition { + name: string + description?: string + options: InternalActionInputField[] +} + +export interface ClientEventDefinition extends EventDefinition {} diff --git a/lib/Data/Model/CustomVariableModel.ts b/lib/Shared/Model/CustomVariableModel.ts similarity index 100% rename from lib/Data/Model/CustomVariableModel.ts rename to lib/Shared/Model/CustomVariableModel.ts diff --git a/lib/Data/Model/EventModel.ts b/lib/Shared/Model/EventModel.ts similarity index 100% rename from lib/Data/Model/EventModel.ts rename to lib/Shared/Model/EventModel.ts diff --git a/lib/Shared/Model/ExportFormat.ts b/lib/Shared/Model/ExportFormat.ts new file mode 100644 index 0000000000..e2e6de0b89 --- /dev/null +++ b/lib/Shared/Model/ExportFormat.ts @@ -0,0 +1 @@ +export type ExportFormat = 'json' | 'json-gz' diff --git a/lib/Data/Model/ExportModel.ts b/lib/Shared/Model/ExportModel.ts similarity index 92% rename from lib/Data/Model/ExportModel.ts rename to lib/Shared/Model/ExportModel.ts index e7271e13ad..6d919da8ca 100644 --- a/lib/Data/Model/ExportModel.ts +++ b/lib/Shared/Model/ExportModel.ts @@ -1,3 +1,4 @@ +import type { UserConfigGridSize } from './UserConfigModel.js' import type { ConnectionConfig } from '../../Instance/Controller.js' import type { CustomVariablesModel } from './CustomVariableModel.js' @@ -34,7 +35,7 @@ export interface ExportPageContentv4 { name: string controls: Record> - gridSize: ExportGridSize + gridSize: UserConfigGridSize } export type ExportControlv4 = Record // TODO @@ -59,10 +60,3 @@ export type ExportInstanceMinimalv4 = { lastUpgradeIndex: number sortOrder?: number } - -export interface ExportGridSize { - minColumn: number - maxColumn: number - minRow: number - maxRow: number -} diff --git a/lib/Data/Model/FeedbackModel.ts b/lib/Shared/Model/FeedbackModel.ts similarity index 100% rename from lib/Data/Model/FeedbackModel.ts rename to lib/Shared/Model/FeedbackModel.ts diff --git a/lib/Shared/Model/ImportExport.ts b/lib/Shared/Model/ImportExport.ts new file mode 100644 index 0000000000..2b92a074e2 --- /dev/null +++ b/lib/Shared/Model/ImportExport.ts @@ -0,0 +1,44 @@ +import type { ExportFormat } from './ExportFormat.js' +import type { UserConfigGridSize } from './UserConfigModel.js' + +export interface ClientResetSelection { + buttons: boolean + connections: boolean + surfaces: boolean + triggers: boolean + customVariables: boolean + userconfig: boolean +} + +export interface ClientExportSelection { + buttons: boolean + triggers: boolean + customVariables: boolean + connections: boolean + surfaces: boolean + + format: ExportFormat +} + +export interface ClientImportSelection { + buttons: boolean + customVariables: boolean + surfaces: boolean + triggers: boolean +} + +export interface ClientPageInfo { + name: string + gridSize: UserConfigGridSize +} +export interface ClientImportObject { + type: 'page' | 'full' + instances: Record + controls: boolean + customVariables: boolean + surfaces: boolean + triggers: boolean | Record + oldPageNumber?: number + page?: ClientPageInfo + pages?: Record +} diff --git a/lib/Shared/Model/LogLine.ts b/lib/Shared/Model/LogLine.ts new file mode 100644 index 0000000000..5015eb6551 --- /dev/null +++ b/lib/Shared/Model/LogLine.ts @@ -0,0 +1,6 @@ +export interface ClientLogLine { + time: number + source: string + level: string + message: string +} diff --git a/lib/Shared/Model/Options.ts b/lib/Shared/Model/Options.ts new file mode 100644 index 0000000000..f590d146a0 --- /dev/null +++ b/lib/Shared/Model/Options.ts @@ -0,0 +1,126 @@ +import type { + CompanionButtonStyleProps, + CompanionInputFieldBase, + CompanionInputFieldCheckbox, + CompanionInputFieldColor, + CompanionInputFieldCustomVariable, + CompanionInputFieldDropdown, + CompanionInputFieldMultiDropdown, + CompanionInputFieldNumber, + CompanionInputFieldStaticText, + CompanionInputFieldTextInput, +} from '@companion-module/base' + +// TODO: move to '@companion-module/base' +export type IsVisibleFunction = Required['isVisible'] + +export type InternalInputFieldType = + | 'internal:time' + | 'internal:variable' + | 'internal:custom_variable' + | 'internal:trigger' + | 'internal:instance_id' + | 'internal:surface_serial' + | 'internal:page' +// export type CompanionInputFieldTypeExtended = CompanionInputFieldBase['type'] +export interface CompanionInputFieldBaseExtended extends Omit { + type: InternalInputFieldType +} + +export interface InternalInputFieldTime extends CompanionInputFieldBaseExtended { + type: 'internal:time' +} +export interface InternalInputFieldVariable extends CompanionInputFieldBaseExtended { + type: 'internal:variable' + // default: string +} +export interface InternalInputFieldCustomVariable extends CompanionInputFieldBaseExtended { + type: 'internal:custom_variable' + includeNone?: boolean +} +export interface InternalInputFieldTrigger extends CompanionInputFieldBaseExtended { + type: 'internal:trigger' + includeSelf?: boolean + default?: string +} +export interface InternalInputFieldInstanceId extends CompanionInputFieldBaseExtended { + type: 'internal:instance_id' + multiple: boolean + includeAll?: boolean + filterActionsRecorder?: boolean + default?: string[] +} +export interface InternalInputFieldSurfaceSerial extends CompanionInputFieldBaseExtended { + type: 'internal:surface_serial' + includeSelf: boolean + default: string + useRawSurfaces?: boolean +} +export interface InternalInputFieldPage extends CompanionInputFieldBaseExtended { + type: 'internal:page' + includeDirection: boolean + default: number +} + +export type InternalInputField = + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + +export interface CompanionInputFieldTextInputExtended extends CompanionInputFieldTextInput { + placeholder?: string + useInternalLocationVariables?: boolean +} +export interface CompanionInputFieldMultiDropdownExtended extends CompanionInputFieldMultiDropdown { + allowCustom?: boolean + regex?: string +} + +export type ExtendedInputField = + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + | EncodeIsVisible2 + +export type EncodeIsVisible2> = Omit & { + isVisibleFn?: string +} + +export type InternalActionInputField = ExtendedInputField | InternalInputField +export type InternalFeedbackInputField = ExtendedInputField | InternalInputField + +export interface ActionDefinition { + label: string + description: string | undefined + options: InternalActionInputField[] + hasLearn?: boolean +} + +export interface FeedbackDefinition { + label: string + description: string | undefined + options: InternalFeedbackInputField[] + type: 'advanced' | 'boolean' + style: Partial | undefined + hasLearn: boolean + showInvert: boolean +} + +export interface InternalFeedbackDefinition extends FeedbackDefinition { + showButtonPreview?: boolean +} + +export interface InternalActionDefinition extends Omit { + showButtonPreview?: boolean + options: InternalActionInputField[] +} + +export interface ClientActionDefinition extends InternalActionDefinition {} diff --git a/lib/Data/Model/PageModel.ts b/lib/Shared/Model/PageModel.ts similarity index 100% rename from lib/Data/Model/PageModel.ts rename to lib/Shared/Model/PageModel.ts diff --git a/lib/Shared/Model/Presets.ts b/lib/Shared/Model/Presets.ts new file mode 100644 index 0000000000..0da0f13bab --- /dev/null +++ b/lib/Shared/Model/Presets.ts @@ -0,0 +1,48 @@ +import type { + CompanionButtonPresetOptions, + CompanionButtonStyleProps, + CompanionOptionValues, +} from '@companion-module/base' +import { ActionStepOptions } from './ActionModel.js' + +export interface PresetFeedbackInstance { + type: string + options: CompanionOptionValues + style: Partial | undefined + isInverted?: boolean +} + +export interface PresetActionInstance { + action: string + options: CompanionOptionValues + delay: number +} + +export interface PresetActionSets { + down: PresetActionInstance[] + up: PresetActionInstance[] + [delay: number]: PresetActionInstance[] +} + +export interface PresetActionSteps { + options?: ActionStepOptions + action_sets: PresetActionSets +} + +export interface PresetDefinition { + id: string + name: string + category: string + type: 'button' + style: CompanionButtonStyleProps + previewStyle: CompanionButtonStyleProps | undefined + options: CompanionButtonPresetOptions | undefined + feedbacks: PresetFeedbackInstance[] + steps: PresetActionSteps[] +} + +export interface UIPresetDefinition { + id: string + label: string + category: string +} diff --git a/lib/Data/Model/README.md b/lib/Shared/Model/README.md similarity index 65% rename from lib/Data/Model/README.md rename to lib/Shared/Model/README.md index ea5d9a4c3b..5835d7a431 100644 --- a/lib/Data/Model/README.md +++ b/lib/Shared/Model/README.md @@ -1,5 +1,5 @@ Care must be taken when editing these types -They are used for both the db and exports, so any changes to these can cause imports or user databases to no longer match up. +Many are used for the ui, the db and exports, so any changes to these can cause imports or user databases to no longer match up. Be sure to make all changes in a backwards compatible way, or if the change is large enough it should increment the db revision and use an upgrade script to convert the previous version into the new one. diff --git a/lib/Data/Model/StyleModel.ts b/lib/Shared/Model/StyleModel.ts similarity index 100% rename from lib/Data/Model/StyleModel.ts rename to lib/Shared/Model/StyleModel.ts diff --git a/lib/Shared/Model/Surfaces.ts b/lib/Shared/Model/Surfaces.ts new file mode 100644 index 0000000000..32277129c1 --- /dev/null +++ b/lib/Shared/Model/Surfaces.ts @@ -0,0 +1,18 @@ +export interface ClientSurfaceItem { + id: string + type: string + integrationType: string + name: string + configFields: string[] + isConnected: boolean + displayName: string + location: string | null +} + +export interface ClientDevicesListItem { + id: string + index: number + displayName: string + isAutoGroup: boolean + surfaces: ClientSurfaceItem[] +} diff --git a/lib/Data/Model/TriggerModel.ts b/lib/Shared/Model/TriggerModel.ts similarity index 77% rename from lib/Data/Model/TriggerModel.ts rename to lib/Shared/Model/TriggerModel.ts index fc2423b6da..ff8b04234a 100644 --- a/lib/Data/Model/TriggerModel.ts +++ b/lib/Shared/Model/TriggerModel.ts @@ -17,3 +17,9 @@ export interface TriggerOptions { sortOrder: number relativeDelay: boolean } + +export interface ClientTriggerData extends TriggerOptions { + type: 'trigger' + lastExecuted: number | undefined + description: string +} diff --git a/lib/Data/Model/UserConfigModel.ts b/lib/Shared/Model/UserConfigModel.ts similarity index 90% rename from lib/Data/Model/UserConfigModel.ts rename to lib/Shared/Model/UserConfigModel.ts index d92442ce2a..8745ce4f0e 100644 --- a/lib/Data/Model/UserConfigModel.ts +++ b/lib/Shared/Model/UserConfigModel.ts @@ -1,5 +1,3 @@ -import { ExportGridSize } from './ExportModel.js' - export interface UserConfigModel { setup_wizard: number @@ -63,6 +61,13 @@ export interface UserConfigModel { admin_timeout: number admin_password: string - gridSize: ExportGridSize + gridSize: UserConfigGridSize gridSizeInlineGrow: boolean } + +export interface UserConfigGridSize { + minColumn: number + maxColumn: number + minRow: number + maxRow: number +} diff --git a/lib/Shared/Model/Variables.ts b/lib/Shared/Model/Variables.ts new file mode 100644 index 0000000000..518be1778e --- /dev/null +++ b/lib/Shared/Model/Variables.ts @@ -0,0 +1,7 @@ +export interface VariableDefinition { + label: string +} + +export type ModuleVariableDefinitions = Record + +export type AllVariableDefinitions = Record diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index 3264063953..289b47c7f0 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -50,7 +50,7 @@ const SurfacesRoom = 'surfaces' class SurfaceController extends CoreBase { /** * The last sent json object - * @type {Record | null} + * @type {Record | null} * @access private */ #lastSentJson = null @@ -307,7 +307,7 @@ class SurfaceController extends CoreBase { 'emulator:startup', /** * @param {string} id - * @returns {import('./IP/ElgatoEmulator.js').EmulatorConfig} + * @returns {import('../Shared/Model/Common.js').EmulatorConfig} */ (id) => { const fullId = EmulatorRoom(id) @@ -847,7 +847,7 @@ class SurfaceController extends CoreBase { updateDevicesList() { const newJsonArr = cloneDeep(this.getDevicesList()) - /** @type {Record} */ + /** @type {Record} */ const newJson = {} for (const surface of newJsonArr) { newJson[surface.id] = surface @@ -1218,9 +1218,9 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ getDeviceIdFromIndex(index) { - for (const dev of this.getDevicesList()) { - if (dev.index === index) { - return dev.id + for (const group of this.getDevicesList()) { + if (group.index === index) { + return group.id } } return undefined @@ -1430,39 +1430,8 @@ class SurfaceController extends CoreBase { export default SurfaceController /** - * @typedef {{ - * id: string - * type: string - * integrationType: string - * name: string - * index: number - * }} BaseDeviceInfo - * - * @typedef {BaseDeviceInfo} OfflineDeviceInfo - * - * @typedef {{ - * location: string - * configFields: string[] - * } & BaseDeviceInfo} AvailableDeviceInfo - * - * @typedef {{ - * id: string - * type: string - * integrationType: string - * name: string - * configFields: string[] - * isConnected: boolean - * displayName: string - * location: string | null - * }} ClientSurfaceItem - * - * @typedef {{ - * id: string - * index: number - * displayName: string - * isAutoGroup: boolean - * surfaces: ClientSurfaceItem[] - * }} ClientDevicesListItem + * @typedef {import('../Shared/Model/Surfaces.js').ClientSurfaceItem} ClientSurfaceItem + * @typedef {import('../Shared/Model/Surfaces.js').ClientDevicesListItem} ClientDevicesListItem */ /** diff --git a/lib/Surface/IP/ElgatoEmulator.js b/lib/Surface/IP/ElgatoEmulator.js index 64f7b7bec9..decf043235 100644 --- a/lib/Surface/IP/ElgatoEmulator.js +++ b/lib/Surface/IP/ElgatoEmulator.js @@ -30,7 +30,7 @@ export function EmulatorRoom(id) { return `emulator:${id}` } -/** @type {EmulatorConfig} */ +/** @type {import('../../Shared/Model/Common.js').EmulatorConfig} */ const DefaultConfig = { emulator_control_enable: false, emulator_prompt_fullscreen: false, @@ -53,7 +53,7 @@ class SurfaceIPElgatoEmulator extends EventEmitter { #io /** - * @type {EmulatorConfig} + * @type {import('../../Shared/Model/Common.js').EmulatorConfig} * @access private */ #lastSentConfigJson = cloneDeep(DefaultConfig) @@ -72,6 +72,7 @@ class SurfaceIPElgatoEmulator extends EventEmitter { #emitChanged = debounceFn( () => { if (this.#pendingBufferUpdates.size > 0) { + /** @type {import('../../Shared/Model/Common.js').EmulatorImage[]} */ const newImages = [] for (const [x, y] of this.#pendingBufferUpdates.values()) { newImages.push({ @@ -128,7 +129,7 @@ class SurfaceIPElgatoEmulator extends EventEmitter { /** * @param {import('../../UI/Handler.js').ClientSocket} client - * @returns {EmulatorConfig} + * @returns {import('../../Shared/Model/Common.js').EmulatorConfig} */ setupClient(client) { client.emit('emulator:images', this.imageCache) @@ -142,7 +143,7 @@ class SurfaceIPElgatoEmulator extends EventEmitter { /** * Process the information from the GUI and what is saved in database - * @param {Record} config + * @param {import('../../Shared/Model/Common.js').EmulatorConfig} config * @param {boolean=} _force * @returns {void} */ @@ -178,7 +179,6 @@ class SurfaceIPElgatoEmulator extends EventEmitter { }) } - // @ts-ignore this.#lastSentConfigJson = cloneDeep(config) } @@ -232,12 +232,3 @@ class SurfaceIPElgatoEmulator extends EventEmitter { } export default SurfaceIPElgatoEmulator - -/** - * @typedef {{ - * emulator_control_enable: boolean, - * emulator_prompt_fullscreen: boolean, - * emulator_columns: number - * emulator_rows: number - * }} EmulatorConfig - */ diff --git a/lib/UI/Handler.js b/lib/UI/Handler.js index 135a60e1c4..27ca842281 100644 --- a/lib/UI/Handler.js +++ b/lib/UI/Handler.js @@ -164,6 +164,7 @@ class UIHandler { const client = new ClientSocket(rawClient, this.#logger) client.onPromise('app-version-info', () => { + /** @type {import('../Shared/Model/Common.js').AppVersionInfo} */ return { appVersion: this.registry.appInfo.appVersion, appBuild: this.registry.appInfo.appBuild, diff --git a/lib/UI/Update.js b/lib/UI/Update.js index c12c2ccb9b..6c663e3f75 100644 --- a/lib/UI/Update.js +++ b/lib/UI/Update.js @@ -36,7 +36,7 @@ class UIUpdate { /** * Latest update information - * @type {Record | null} + * @type {import('../Shared/Model/Common.js').AppUpdateInfo | null} * @access private */ #latestUpdateData = null diff --git a/webui/index.html b/webui/index.html index 5c3000b0bb..7992b677fa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -30,6 +30,6 @@
- + diff --git a/webui/package.json b/webui/package.json index 93a2fc287a..45158a6301 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,6 +14,11 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@hello-pangea/color-picker": "https://github.com/Julusian/color-picker/releases/download/v3.3.0-julusian.0/hello-pangea-color-picker-v3.3.0-julusian.0.tgz", "@popperjs/core": "^2.11.8", + "@types/react": "^17", + "@types/react-copy-to-clipboard": "^5.0.7", + "@types/react-dom": "^18.2.15", + "@types/react-window": "^1.8.8", + "@types/sanitize-html": "^2.9.4", "@vitejs/plugin-react": "^4.1.1", "buffer": "^6.0.3", "classnames": "^2.3.2", @@ -53,6 +58,7 @@ "sass": "^1.69.5", "socket.io-client": "^4.7.2", "tributejs": "^5.1.3", + "typescript": "~5.2", "use-deep-compare": "^1.1.0", "usehooks-ts": "^2.9.1", "vite": "^4.5.0", @@ -62,7 +68,9 @@ "start": "vite", "dev": "yarn start", "build": "vite build", - "serve": "vite preview" + "serve": "vite preview", + "check-types": "tsc --noEmit", + "watch-types": "tsc --noEmit --watch" }, "eslintConfig": { "extends": [ diff --git a/webui/src/App.jsx b/webui/src/App.tsx similarity index 90% rename from webui/src/App.jsx rename to webui/src/App.tsx index 782fb946e3..5786b9918d 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.tsx @@ -39,7 +39,7 @@ import { ConnectionsPage } from './Connections' import { ButtonsPage } from './Buttons' import { ContextData } from './ContextData' import { CloudPage } from './CloudPage' -import { WizardModal, WIZARD_CURRENT_VERSION } from './Wizard' +import { WizardModal, WIZARD_CURRENT_VERSION, WizardModalRef } from './Wizard' import { Navigate, useLocation } from 'react-router-dom' import { useIdleTimer } from 'react-idle-timer' import { ImportExport } from './ImportExport' @@ -58,7 +58,7 @@ export default function App() { const onConnected = () => { setWasConnected((wasConnected0) => { if (wasConnected0) { - window.location.reload(true) + window.location.reload() } else { setConnected(true) } @@ -162,7 +162,14 @@ export default function App() { ) } -function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPress }) { +interface AppMainProps { + connected: boolean + loadingComplete: boolean + loadingProgress: number + buttonGridHotPress: boolean +} + +function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPress }: AppMainProps) { const config = useContext(UserConfigContext) const [showSidebar, setShowSidebar] = useState(true) @@ -178,10 +185,10 @@ function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPre } }, [canLock]) - const wizardModal = useRef() + const wizardModal = useRef(null) const showWizard = useCallback(() => { if (unlocked) { - wizardModal.current.show() + wizardModal.current?.show() } }, [unlocked]) @@ -229,16 +236,21 @@ function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPre ) } +interface IdleTimerWrapperProps { + setLocked: () => void + timeoutMinutes: number +} + /** Wrap the idle timer in its own component, as it invalidates every second */ -function IdleTimerWrapper({ setLocked, timeoutMinutes }) { +function IdleTimerWrapper({ setLocked, timeoutMinutes }: IdleTimerWrapperProps) { const notifier = useContext(NotifierContext) - const [, setIdleTimeout] = useState(null) + const [, setIdleTimeout] = useState(null) const TOAST_ID = 'SESSION_TIMEOUT_TOAST' const TOAST_DURATION = 45 * 1000 - const handleOnActive = (event) => { + const handleOnActive = () => { // user is now active, abort the lock setIdleTimeout((v) => { if (v) { @@ -253,15 +265,15 @@ function IdleTimerWrapper({ setLocked, timeoutMinutes }) { return null }) } - const handleAction = (event) => { + const handleAction = () => { // setShouldShowIdleWarning(false) } const handleIdle = () => { - notifier.current.show( + notifier.current?.show( 'Session timeout', 'Your session is about to timeout, and Companion will be locked', - null, + undefined, TOAST_ID ) @@ -305,10 +317,15 @@ function IdleTimerWrapper({ setLocked, timeoutMinutes }) { } }) - return '' + return null +} + +interface AppLoadingProps { + progress: number + connected: boolean } -function AppLoading({ progress, connected }) { +function AppLoading({ progress, connected }: AppLoadingProps) { const message = connected ? 'Syncing' : 'Connecting' return ( @@ -325,7 +342,11 @@ function AppLoading({ progress, connected }) { ) } -function AppAuthWrapper({ setUnlocked }) { +interface AppAuthWrapperProps { + setUnlocked: () => void +} + +function AppAuthWrapper({ setUnlocked }: AppAuthWrapperProps) { const config = useContext(UserConfigContext) const [password, setPassword] = useState('') @@ -341,7 +362,7 @@ function AppAuthWrapper({ setUnlocked }) { e.preventDefault() setPassword((currentPassword) => { - if (currentPassword === config.admin_password) { + if (currentPassword === config?.admin_password) { setShowError(false) setUnlocked() return '' @@ -354,7 +375,7 @@ function AppAuthWrapper({ setUnlocked }) { return false }, - [config.admin_password, setUnlocked] + [config?.admin_password, setUnlocked] ) return ( @@ -370,6 +391,7 @@ function AppAuthWrapper({ setUnlocked }) { value={password} onChange={(e) => passwordChanged(e.currentTarget.value)} invalid={showError} + readOnly={!config} /> Unlock @@ -382,10 +404,14 @@ function AppAuthWrapper({ setUnlocked }) { ) } -function AppContent({ buttonGridHotPress }) { +interface AppContentProps { + buttonGridHotPress: boolean +} + +function AppContent({ buttonGridHotPress }: AppContentProps) { const routerLocation = useLocation() let hasMatchedPane = false - const getClassForPane = (prefix) => { + const getClassForPane = (prefix: string) => { // Require the path to be the same, or to be a prefix with a sub-route if (routerLocation.pathname.startsWith(prefix + '/') || routerLocation.pathname === prefix) { hasMatchedPane = true diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.tsx similarity index 70% rename from webui/src/Buttons/ActionRecorder.jsx rename to webui/src/Buttons/ActionRecorder.tsx index b87728eb33..c2f3fd2862 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState, useRef } from 'react' +import React, { useCallback, useContext, useEffect, useState, useRef, RefObject, ChangeEvent } from 'react' import { ConnectionsContext, socketEmitPromise, @@ -35,24 +35,29 @@ import { faCalendarAlt, faClock, faHome } from '@fortawesome/free-solid-svg-icon import { useMemo } from 'react' import { DropdownInputField } from '../Components' import { ActionsList } from '../Controls/ActionSetEditor' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { ButtonGridHeader } from './ButtonGridHeader' import { usePagePicker } from '../Hooks/usePagePicker' import { cloneDeep } from 'lodash-es' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { usePanelCollapseHelper } from '../Helpers/CollapseHelper' import CSwitch from '../CSwitch' import { MenuPortalContext } from '../Components/DropdownInputField' -import { ButtonGridIcon, ButtonInfiniteGrid } from './ButtonInfiniteGrid' +import { ButtonGridIcon, ButtonInfiniteGrid, ButtonInfiniteGridRef } from './ButtonInfiniteGrid' import { useHasBeenRendered } from '../Hooks/useHasBeenRendered' +import type { DropdownChoiceId } from '@companion-module/base' +import type { ControlLocation } from '@companion/shared/Model/Common' +import type { ClientTriggerData } from '@companion/shared/Model/TriggerModel' +import type { RecordSessionInfo, RecordSessionListInfo } from '@companion/shared/Model/ActionRecorderModel' +import type { NormalButtonModel } from '@companion/shared/Model/ButtonModel' export function ActionRecorder() { const socket = useContext(SocketContext) const confirmRef = useRef(null) - const [sessions, setSessions] = useState(null) - const [selectedSessionId, setSelectedSessionId] = useState(null) + const [sessions, setSessions] = useState | null>(null) + const [selectedSessionId, setSelectedSessionId] = useState(null) const [isFinishing, setIsFinishing] = useState(false) // Subscribe to the list of sessions @@ -68,8 +73,8 @@ export function ActionRecorder() { console.error('Action record subscribe', e) }) - const updateSessionList = (newSessions) => { - setSessions((oldSessions) => applyPatchOrReplaceObject(oldSessions, newSessions)) + const updateSessionList = (newSessions: JsonPatchOperation[]) => { + setSessions((oldSessions) => oldSessions && applyPatchOrReplaceObject(oldSessions, newSessions)) } socket.on('action-recorder:session-list', updateSessionList) @@ -86,7 +91,7 @@ export function ActionRecorder() { // Ensure the sessionId remains valid useEffect(() => { setSelectedSessionId((oldId) => { - return sessions && sessions[oldId] ? oldId : Object.keys(sessions || {}).sort()[0] || null + return sessions && oldId && sessions[oldId] ? oldId : Object.keys(sessions || {}).sort()[0] || null }) }, [sessions]) @@ -94,33 +99,32 @@ export function ActionRecorder() { setIsFinishing(false) }, [selectedSessionId]) - const [sessionInfo, setSessionInfo] = useState(null) + const [sessionInfo, setSessionInfo] = useState(null) useEffect(() => { setSessionInfo(null) - if (selectedSessionId) { - socketEmitPromise(socket, 'action-recorder:session:subscribe', [selectedSessionId]) - .then((info) => { - setSessionInfo(info) - }) - .catch((e) => { - console.error('Action record session subscribe', e) - }) + if (!selectedSessionId) return + socketEmitPromise(socket, 'action-recorder:session:subscribe', [selectedSessionId]) + .then((info) => { + setSessionInfo(info) + }) + .catch((e) => { + console.error('Action record session subscribe', e) + }) - const updateSessionInfo = (patch) => { - setSessionInfo((oldInfo) => applyPatchOrReplaceObject(oldInfo, patch)) - } + const updateSessionInfo = (patch: JsonPatchOperation[]) => { + setSessionInfo((oldInfo) => oldInfo && applyPatchOrReplaceObject(oldInfo, patch)) + } - socket.on(`action-recorder:session:update:${selectedSessionId}`, updateSessionInfo) + socket.on(`action-recorder:session:update:${selectedSessionId}`, updateSessionInfo) - return () => { - socketEmitPromise(socket, 'action-recorder:session:unsubscribe', [selectedSessionId]).catch((e) => { - console.error('Action record subscribe', e) - }) + return () => { + socketEmitPromise(socket, 'action-recorder:session:unsubscribe', [selectedSessionId]).catch((e) => { + console.error('Action record subscribe', e) + }) - socket.off(`action-recorder:session:update:${selectedSessionId}`, updateSessionInfo) - } + socket.off(`action-recorder:session:update:${selectedSessionId}`, updateSessionInfo) } }, [socket, selectedSessionId]) @@ -135,7 +139,11 @@ export function ActionRecorder() { - {isFinishing ? : ''} + {isFinishing && selectedSessionId ? ( + + ) : ( + '' + )}
Action Recorder
@@ -144,12 +152,14 @@ export function ActionRecorder() { Not many modules support this, and they don't support it for every action.

- + {selectedSessionId && sessionInfo && ( + + )}
@@ -162,11 +172,16 @@ export function ActionRecorder() { ) } -function RecorderSessionFinishModal({ doClose, sessionId }) { +interface RecorderSessionFinishModalProps { + doClose: () => void + sessionId: string +} + +function RecorderSessionFinishModal({ doClose, sessionId }: RecorderSessionFinishModalProps) { const socket = useContext(SocketContext) const doSave = useCallback( - (controlId, stepId, setId, mode) => { + (controlId: string, stepId: string | null, setId: string | null, mode: 'replace' | 'append') => { socketEmitPromise(socket, 'action-recorder:session:save-to-control', [sessionId, controlId, stepId, setId, mode]) .then(() => { doClose() @@ -226,16 +241,20 @@ function RecorderSessionFinishModal({ doClose, sessionId }) { ) } -function ButtonPicker({ selectButton }) { +interface ButtonPickerProps { + selectButton: (selectedControl: string, selectedStep: string, selectedSet: string, mode: 'replace' | 'append') => void +} + +function ButtonPicker({ selectButton }: ButtonPickerProps) { const socket = useContext(SocketContext) const pages = useContext(PagesContext) const userConfig = useContext(UserConfigContext) const { pageNumber, setPageNumber, changePage } = usePagePicker(pages, 1) - const [selectedLocation, setSelectedLocation] = useState(null) - const [selectedStep, setSelectedStep] = useState(null) - const [selectedSet, setSelectedSet] = useState(null) + const [selectedLocation, setSelectedLocation] = useState(null) + const [selectedStep, setSelectedStep] = useState(null) + const [selectedSet, setSelectedSet] = useState(null) const buttonClick = useCallback( (location, pressed) => { @@ -252,46 +271,47 @@ function ButtonPicker({ selectButton }) { useEffect(() => setSelectedSet(null), [selectedControl]) const replaceActions = useCallback(() => { - selectButton(selectedControl, selectedStep, selectedSet, 'replace') + if (selectedControl && selectedStep && selectedSet) + selectButton(selectedControl, selectedStep, selectedSet, 'replace') }, [selectedControl, selectedStep, selectedSet, selectButton]) const appendActions = useCallback(() => { - selectButton(selectedControl, selectedStep, selectedSet, 'append') + if (selectedControl && selectedStep && selectedSet) + selectButton(selectedControl, selectedStep, selectedSet, 'append') }, [selectedControl, selectedStep, selectedSet, selectButton]) - const [controlInfo, setControlInfo] = useState(null) + const [controlInfo, setControlInfo] = useState(null) useEffect(() => { setControlInfo(null) - if (selectedControl) { - socketEmitPromise(socket, 'controls:subscribe', [selectedControl]) - .then((config) => { - console.log(config) - setControlInfo(config?.config ?? false) - }) - .catch((e) => { - console.error('Failed to load control config', e) - setControlInfo(null) - }) + if (!selectedControl) return + socketEmitPromise(socket, 'controls:subscribe', [selectedControl]) + .then((config) => { + console.log(config) + setControlInfo(config?.config ?? false) + }) + .catch((e) => { + console.error('Failed to load control config', e) + setControlInfo(null) + }) - const patchConfig = (patch) => { - setControlInfo((oldConfig) => { - if (patch === false) { - return false - } else { - return jsonPatch.applyPatch(cloneDeep(oldConfig) || {}, patch).newDocument - } - }) - } + const patchConfig = (patch: JsonPatchOperation[] | false) => { + setControlInfo((oldConfig) => { + if (!oldConfig || patch === false) { + return null + } else { + return jsonPatch.applyPatch(cloneDeep(oldConfig) || {}, patch).newDocument + } + }) + } - socket.on(`controls:config-${selectedControl}`, patchConfig) + socket.on(`controls:config-${selectedControl}`, patchConfig) - return () => { - socket.off(`controls:config-${selectedControl}`, patchConfig) + return () => { + socket.off(`controls:config-${selectedControl}`, patchConfig) - socketEmitPromise(socket, 'controls:unsubscribe', [selectedControl]).catch((e) => { - console.error('Failed to unsubscribe control config', e) - }) - } + socketEmitPromise(socket, 'controls:unsubscribe', [selectedControl]).catch((e) => { + console.error('Failed to unsubscribe control config', e) + }) } }, [socket, selectedControl]) @@ -307,7 +327,7 @@ function ButtonPicker({ selectButton }) { } }, [controlInfo?.type, controlInfo?.steps]) - const selectedStepInfo = controlInfo?.steps?.[selectedStep] + const selectedStepInfo = selectedStep ? controlInfo?.steps?.[selectedStep] : null const actionSetOptions = useMemo(() => { switch (controlInfo?.type) { case 'button': { @@ -335,7 +355,7 @@ function ButtonPicker({ selectButton }) { ) } - const candidate_sets = Object.keys(selectedStepInfo?.action_sets || {}).filter((id) => !isNaN(id)) + const candidate_sets = Object.keys(selectedStepInfo?.action_sets || {}).filter((id) => !isNaN(Number(id))) candidate_sets.sort((a, b) => Number(a) - Number(b)) for (const set of candidate_sets) { @@ -372,11 +392,11 @@ function ButtonPicker({ selectButton }) { }) }, [actionSetOptions]) - const gridSize = userConfig.gridSize + const gridSize = userConfig?.gridSize const [hasBeenInView, isInViewRef] = useHasBeenRendered() - const gridRef = useRef(null) + const gridRef = useRef(null) const resetPosition = useCallback(() => { gridRef.current?.resetPosition() }, [gridRef]) @@ -398,7 +418,7 @@ function ButtonPicker({ selectButton }) {
- {hasBeenInView && ( + {hasBeenInView && gridSize && ( void} disabled={!controlInfo} /> @@ -429,8 +449,8 @@ function ButtonPicker({ selectButton }) { void} disabled={!controlInfo} /> @@ -461,7 +481,13 @@ function ButtonPicker({ selectButton }) { ) } -function TriggerPickerRow({ id, trigger, selectTrigger }) { +interface TriggerPickerRowProps { + id: string + trigger: ClientTriggerData + selectTrigger: (id: string, mode: 'replace' | 'append') => void +} + +function TriggerPickerRow({ id, trigger, selectTrigger }: TriggerPickerRowProps) { const replaceActions = useCallback(() => selectTrigger(id, 'replace'), [id, selectTrigger]) const appendActions = useCallback(() => selectTrigger(id, 'append'), [id, selectTrigger]) @@ -481,11 +507,16 @@ function TriggerPickerRow({ id, trigger, selectTrigger }) { ) } -function TriggerPicker({ selectControl }) { + +interface TriggerPickerProps { + selectControl: (controlId: string, stepId: string | null, setId: string | null, mode: 'append' | 'replace') => void +} + +function TriggerPicker({ selectControl }: TriggerPickerProps) { const triggersList = useContext(TriggersContext) const selectTrigger = useCallback( - (id, mode) => selectControl(CreateTriggerControlId(id), null, null, mode), + (id: string, mode: 'append' | 'replace') => selectControl(CreateTriggerControlId(id), null, null, mode), [selectControl] ) @@ -500,12 +531,12 @@ function TriggerPicker({ selectControl }) {
{triggersList && Object.keys(triggersList).length > 0 ? ( - Object.entries(triggersList).map(([id, item]) => ( - - )) + Object.entries(triggersList).map( + ([id, item]) => item && + ) ) : ( - @@ -516,7 +547,14 @@ function TriggerPicker({ selectControl }) { ) } -function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }) { +interface RecorderSessionHeadingProps { + confirmRef: RefObject + sessionId: string + sessionInfo: RecordSessionInfo + doFinish: () => void +} + +function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }: RecorderSessionHeadingProps) { const socket = useContext(SocketContext) const connections = useContext(ConnectionsContext) @@ -542,7 +580,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } }, [socket, sessionId, confirmRef]) const changeRecording = useCallback( - (e) => { + (e: ChangeEvent | boolean) => { socketEmitPromise(socket, 'action-recorder:session:recording', [ sessionId, typeof e === 'boolean' ? e : e.target.checked, @@ -560,7 +598,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } }, [changeRecording, doFinish]) const changeConnectionIds = useCallback( - (ids) => { + (ids: DropdownChoiceId[]) => { socketEmitPromise(socket, 'action-recorder:session:set-connections', [sessionId, ids]).catch((e) => { console.error(e) }) @@ -583,8 +621,6 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } return result }, [connections]) - if (!sessionInfo) return <> - return ( <> @@ -592,7 +628,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }
Connections - value={sessionInfo.connectionIds} setValue={changeConnectionIds} multiple={true} @@ -627,11 +663,16 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } ) } -function RecorderSession({ sessionId, sessionInfo }) { +interface RecorderSessionProps { + sessionId: string + sessionInfo: RecordSessionInfo | null +} + +function RecorderSession({ sessionId, sessionInfo }: RecorderSessionProps) { const socket = useContext(SocketContext) const doActionDelete = useCallback( - (actionId) => { + (actionId: string) => { socketEmitPromise(socket, 'action-recorder:session:action-delete', [sessionId, actionId]).catch((e) => { console.error(e) }) @@ -639,7 +680,7 @@ function RecorderSession({ sessionId, sessionInfo }) { [socket, sessionId] ) const doActionDuplicate = useCallback( - (actionId) => { + (actionId: string) => { socketEmitPromise(socket, 'action-recorder:session:action-duplicate', [sessionId, actionId]).catch((e) => { console.error(e) }) @@ -647,7 +688,7 @@ function RecorderSession({ sessionId, sessionInfo }) { [socket, sessionId] ) const doActionDelay = useCallback( - (actionId, delay) => { + (actionId: string, delay: number) => { socketEmitPromise(socket, 'action-recorder:session:action-delay', [sessionId, actionId, delay]).catch((e) => { console.error(e) }) @@ -655,7 +696,7 @@ function RecorderSession({ sessionId, sessionInfo }) { [socket, sessionId] ) const doActionSetValue = useCallback( - (actionId, key, value) => { + (actionId: string, key: string, value: any) => { socketEmitPromise(socket, 'action-recorder:session:action-set-value', [sessionId, actionId, key, value]).catch( (e) => { console.error(e) @@ -665,7 +706,14 @@ function RecorderSession({ sessionId, sessionInfo }) { [socket, sessionId] ) const doActionReorder = useCallback( - (_dragStepId, _dragSetId, dragIndex, _dropStepId, _dropSetId, dropIndex) => { + ( + _dragStepId: string, + _dragSetId: string | number, + dragIndex: number, + _dropStepId: string, + _dropSetId: string | number, + dropIndex: number + ) => { socketEmitPromise(socket, 'action-recorder:session:action-reorder', [sessionId, dragIndex, dropIndex]).catch( (e) => { console.error(e) @@ -686,6 +734,8 @@ function RecorderSession({ sessionId, sessionInfo }) { void +} +interface ButtonGridActionsProps { + isHot: boolean + pageNumber: number + clearSelectedButton: () => void +} + +export const ButtonGridActions = forwardRef(function ButtonGridActions( { isHot, pageNumber, clearSelectedButton }, ref ) { const socket = useContext(SocketContext) - const resetRef = useRef() + const resetRef = useRef(null) - const [activeFunction, setActiveFunction] = useState(null) - const [activeFunctionButton, setActiveFunctionButton] = useState(null) + const [activeFunction, setActiveFunction] = useState(null) + const [activeFunctionButton, setActiveFunctionButton] = useState(null) let hintText = '' if (activeFunction) { @@ -28,7 +39,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( } const startFunction = useCallback( - (func) => { + (func: string) => { setActiveFunction((oldFunction) => { if (oldFunction === null) { setActiveFunctionButton(null) @@ -49,7 +60,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( const [setSizeRef, holderSize] = useElementSize() const useCompactButtons = holderSize.width < 600 // Cutoff for what of the action buttons fit in their large mode - const getButton = (label, icon, func) => { + const getButton = (label: string, icon: IconProp, func: string) => { let color = 'light' let disabled = false if (activeFunction === func) { @@ -70,7 +81,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( const resetPage = useCallback(() => { clearSelectedButton() - resetRef.current.show( + resetRef.current?.show( 'Reset page', `Are you sure you want to clear all buttons on page ${pageNumber}?\nThere's no going back from this.`, 'Reset', @@ -84,7 +95,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( const resetPageNav = useCallback(() => { clearSelectedButton() - resetRef.current.show( + resetRef.current?.show( 'Reset page', `Are you sure you want to reset navigation buttons? This will completely erase button ${pageNumber}/0/0, ${pageNumber}/1/0 and ${pageNumber}/2/0`, 'Reset', @@ -103,7 +114,7 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( if (isDown) { switch (activeFunction) { case 'delete': - resetRef.current.show('Clear button', `Clear style and actions for this button?`, 'Clear', () => { + resetRef.current?.show('Clear button', `Clear style and actions for this button?`, 'Clear', () => { socketEmitPromise(socket, 'controls:reset', [location]).catch((e) => { console.error(`Reset failed: ${e}`) }) diff --git a/webui/src/Buttons/ButtonGridHeader.jsx b/webui/src/Buttons/ButtonGridHeader.tsx similarity index 63% rename from webui/src/Buttons/ButtonGridHeader.jsx rename to webui/src/Buttons/ButtonGridHeader.tsx index 9cf0aef880..067b2f38f2 100644 --- a/webui/src/Buttons/ButtonGridHeader.jsx +++ b/webui/src/Buttons/ButtonGridHeader.tsx @@ -4,14 +4,27 @@ import { PagesContext } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons' import Select from 'react-select' +import { PageModel } from '@companion/shared/Model/PageModel' +import { DropdownChoice } from '@companion-module/base' -export const ButtonGridHeader = memo(function ButtonGridHeader({ pageNumber, changePage, setPage, children }) { +interface ButtonGridHeaderProps { + pageNumber: number + changePage?: (delta: number) => void + setPage?: (page: number) => void +} + +export const ButtonGridHeader = memo>(function ButtonGridHeader({ + pageNumber, + changePage, + setPage, + children, +}) { const pagesContext = useContext(PagesContext) const inputChange = useCallback( (val) => { const val2 = Number(val?.value) - if (!isNaN(val2)) { + if (setPage && !isNaN(val2)) { setPage(val2) } }, @@ -19,21 +32,23 @@ export const ButtonGridHeader = memo(function ButtonGridHeader({ pageNumber, cha ) const nextPage = useCallback(() => { - changePage(1) + changePage?.(1) }, [changePage]) const prevPage = useCallback(() => { - changePage(-1) + changePage?.(-1) }, [changePage]) - const pageOptions = useMemo(() => { - return Object.entries(pagesContext).map(([index, value]) => ({ - value: index, - label: `${index} (${value.name})`, - })) + const pageOptions: DropdownChoice[] = useMemo(() => { + return Object.entries(pagesContext) + .filter((pg): pg is [string, PageModel] => !!pg[1]) + .map(([index, value]) => ({ + id: index, + label: `${index} (${value.name})`, + })) }, [pagesContext]) - const currentValue = useMemo(() => { - return pageOptions.find((o) => o.value == pageNumber) ?? { value: pageNumber, label: pageNumber } + const currentValue: DropdownChoice | undefined = useMemo(() => { + return pageOptions.find((o) => o.id == pageNumber) ?? { id: pageNumber, label: pageNumber + '' } }, [pageOptions, pageNumber]) return ( diff --git a/webui/src/Buttons/ButtonGridPanel.jsx b/webui/src/Buttons/ButtonGridPanel.tsx similarity index 61% rename from webui/src/Buttons/ButtonGridPanel.jsx rename to webui/src/Buttons/ButtonGridPanel.tsx index 6268a555b0..baea2c0e70 100644 --- a/webui/src/Buttons/ButtonGridPanel.jsx +++ b/webui/src/Buttons/ButtonGridPanel.tsx @@ -13,6 +13,7 @@ import { CRow, } from '@coreui/react' import React, { + FormEvent, forwardRef, memo, useCallback, @@ -25,12 +26,24 @@ import React, { import { KeyReceiver, PagesContext, socketEmitPromise, SocketContext, UserConfigContext } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faFileExport, faHome, faPencil } from '@fortawesome/free-solid-svg-icons' -import { ConfirmExportModal } from '../Components/ConfirmExportModal' -import { ButtonInfiniteGrid, PrimaryButtonGridIcon } from './ButtonInfiniteGrid' +import { ConfirmExportModal, ConfirmExportModalRef } from '../Components/ConfirmExportModal' +import { ButtonInfiniteGrid, ButtonInfiniteGridRef, PrimaryButtonGridIcon } from './ButtonInfiniteGrid' import { useHasBeenRendered } from '../Hooks/useHasBeenRendered' import { useElementSize } from 'usehooks-ts' import { ButtonGridHeader } from './ButtonGridHeader' -import { ButtonGridActions } from './ButtonGridActions' +import { ButtonGridActions, ButtonGridActionsRef } from './ButtonGridActions' +import type { ControlLocation } from '@companion/shared/Model/Common' +import type { PageModel } from '@companion/shared/Model/PageModel' + +interface ButtonsGridPanelProps { + pageNumber: number + onKeyDown: (event: React.KeyboardEvent) => void + isHot: boolean + buttonGridClick: (location: ControlLocation, pressed: boolean) => void + changePage: (pageNumber: number) => void + selectedButton: ControlLocation | null + clearSelectedButton: () => void +} export const ButtonsGridPanel = memo(function ButtonsPage({ pageNumber, @@ -40,18 +53,18 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ changePage, selectedButton, clearSelectedButton, -}) { +}: ButtonsGridPanelProps) { const socket = useContext(SocketContext) const pages = useContext(PagesContext) const userConfig = useContext(UserConfigContext) - const pagesRef = useRef() + const pagesRef = useRef>() useEffect(() => { // Avoid binding into callbacks pagesRef.current = pages }, [pages]) - const actionsRef = useRef() + const actionsRef = useRef(null) const buttonClick = useCallback( (location, isDown) => { @@ -86,7 +99,7 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ newPage = pageNumbers[newIndex] } - if (newPage !== undefined && !isNaN(newPage)) { + if (newPage !== undefined && !isNaN(Number(newPage))) { changePage(Number(newPage)) } }, @@ -95,12 +108,12 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ const pageInfo = pages?.[pageNumber] - const gridRef = useRef(null) - const editRef = useRef(null) + const gridRef = useRef(null) + const editRef = useRef(null) - const exportModalRef = useRef(null) + const exportModalRef = useRef(null) const showExportModal = useCallback(() => { - exportModalRef.current.show(`/int/export/page/${pageNumber}`) + exportModalRef.current?.show(`/int/export/page/${pageNumber}`) }, [pageNumber]) const resetPosition = useCallback(() => { @@ -111,11 +124,11 @@ export const ButtonsGridPanel = memo(function ButtonsPage({ editRef.current?.show(Number(pageNumber), pageInfo) }, [pageNumber, pageInfo]) - const gridSize = userConfig.gridSize + const gridSize = userConfig?.gridSize const doGrow = useCallback( (direction, amount) => { - if (amount <= 0) return + if (amount <= 0 || !gridSize) return switch (direction) { case 'left': @@ -183,7 +196,7 @@ export const ButtonsGridPanel = memo(function ButtonsPage({
- {hasBeenInView && ( + {hasBeenInView && gridSize && ( ( + function EditPagePropertiesModal(_props, ref) { + const socket = useContext(SocketContext) + const [pageNumber, setPageNumber] = useState(null) + const [show, setShow] = useState(false) - const buttonFocus = () => { - if (buttonRef.current) { - buttonRef.current.focus() - } - } + const [pageName, setName] = useState(null) - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setPageNumber(null), []) - const doAction = useCallback( - (e) => { - if (e) e.preventDefault() + const buttonRef = useRef(null) - setPageNumber(null) - setShow(false) - setName(null) + const buttonFocus = () => { + if (buttonRef.current) { + buttonRef.current.focus() + } + } - if (pageNumber === null) return + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => setPageNumber(null), []) + const doAction = useCallback( + (e: FormEvent) => { + if (e) e.preventDefault() - socketEmitPromise(socket, 'pages:set-name', [pageNumber, pageName]).catch((e) => { - console.error('Failed to set name', e) - }) - }, - [pageNumber, pageName] - ) + setPageNumber(null) + setShow(false) + setName(null) - useImperativeHandle( - ref, - () => ({ - show(pageNumber, pageInfo) { - setName(pageInfo?.name) - setPageNumber(pageNumber) - setShow(true) + if (pageNumber === null) return - // Focus the button asap. It also gets focused once the open is complete - setTimeout(buttonFocus, 50) + socketEmitPromise(socket, 'pages:set-name', [pageNumber, pageName]).catch((e) => { + console.error('Failed to set name', e) + }) }, - }), - [] - ) - - const onNameChange = useCallback((e) => { - setName(e.target.value) - }, []) - - return ( - - -
Configure Page {pageNumber}
-
- - - - Name - - - - You can use resize the grid in the Settings tab - - - - - Cancel - - - Save - - -
- ) -}) + [pageNumber, pageName] + ) + + useImperativeHandle( + ref, + () => ({ + show(pageNumber, pageInfo) { + setName(pageInfo?.name ?? null) + setPageNumber(pageNumber) + setShow(true) + + // Focus the button asap. It also gets focused once the open is complete + setTimeout(buttonFocus, 50) + }, + }), + [] + ) + + const onNameChange = useCallback((e) => { + setName(e.target.value) + }, []) + + return ( + + +
Configure Page {pageNumber}
+
+ + + + Name + + + + You can use resize the grid in the Settings tab + + + + + Cancel + + + Save + + +
+ ) + } +) diff --git a/webui/src/Buttons/ButtonInfiniteGrid.jsx b/webui/src/Buttons/ButtonInfiniteGrid.jsx deleted file mode 100644 index 79493d667c..0000000000 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ /dev/null @@ -1,269 +0,0 @@ -import { formatLocation } from '@companion/shared/ControlId' -import { ButtonPreview } from '../Components/ButtonPreview' -import React, { - forwardRef, - memo, - useCallback, - useContext, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import { useDrop } from 'react-dnd' -import { SocketContext, socketEmitPromise } from '../util' -import classNames from 'classnames' -import useScrollPosition from '../Hooks/useScrollPosition' -import useElementInnerSize from '../Hooks/useElementInnerSize' -import { useButtonRenderCache } from '../Hooks/useSharedRenderCache' -import { CButton, CInput } from '@coreui/react' - -export const ButtonInfiniteGrid = forwardRef(function ButtonInfiniteGrid( - { isHot, pageNumber, buttonClick, selectedButton, gridSize, doGrow, buttonIconFactory }, - ref -) { - const { minColumn, maxColumn, minRow, maxRow } = gridSize - const countColumns = maxColumn - minColumn + 1 - const countRows = maxRow - minRow + 1 - - const tileSize = 84 - const growWidth = doGrow ? 90 : 0 - const growHeight = doGrow ? 60 : 0 - - const [setSizeElement, windowSize] = useElementInnerSize() - const { scrollX, scrollY, setRef: setScrollRef } = useScrollPosition() - - // Reposition the window to have 0/0 in the top left - const [scrollerRef, setScrollerRef] = useState(null) - const resetScrollPosition = useCallback(() => { - if (scrollerRef) { - scrollerRef.scrollTop = -minRow * tileSize + growHeight - scrollerRef.scrollLeft = -minColumn * tileSize + growWidth - } - }, [scrollerRef, minColumn, minRow, tileSize, growWidth, growHeight]) - - const setRef = useCallback( - (ref) => { - setSizeElement(ref) - setScrollRef(ref) - - setScrollerRef(ref) - }, - [setSizeElement, setScrollRef] - ) - - // Reset the position when the element changes - useEffect(() => resetScrollPosition(), [scrollerRef]) - - // Expose reload to the parent - useImperativeHandle( - ref, - () => ({ - resetPosition() { - resetScrollPosition() - }, - }), - [resetScrollPosition] - ) - - const visibleColumns = windowSize.width / tileSize - const visibleRows = windowSize.height / tileSize - - // Calculate the extents of what is visible - const scrollColumn = scrollX / tileSize - const scrollRow = scrollY / tileSize - const visibleMinX = minColumn + scrollColumn - const visibleMaxX = visibleMinX + visibleColumns - const visibleMinY = minRow + scrollRow - const visibleMaxY = visibleMinY + visibleRows - - // Calculate the bounds of what to draw in the DOM - // Include some spill to make scrolling smoother, but not too much to avoid being a performance drain - const drawMinColumn = Math.max(Math.floor(visibleMinX - visibleColumns / 2), minColumn) - const drawMaxColumn = Math.min(Math.ceil(visibleMaxX + visibleColumns / 2), maxColumn) - const drawMinRow = Math.max(Math.floor(visibleMinY - visibleRows / 2), minRow) - const drawMaxRow = Math.min(Math.ceil(visibleMaxY + visibleRows / 2), maxRow) - - const visibleButtons = [] - for (let row = drawMinRow; row <= drawMaxRow; row++) { - for (let column = drawMinColumn; column <= drawMaxColumn; column++) { - visibleButtons.push( - React.createElement(buttonIconFactory, { - key: `${column}_${row}`, - - fixedSize: true, - row, - column, - pageNumber, - onClick: buttonClick, - selected: - selectedButton?.pageNumber === pageNumber && - selectedButton?.column === column && - selectedButton?.row === row, - left: (column - minColumn) * tileSize + growWidth, - top: (row - minRow) * tileSize + growHeight, - }) - ) - } - } - - const growTopRef = useRef(null) - const growBottomRef = useRef(null) - const growLeftRef = useRef(null) - const growRightRef = useRef(null) - - const doGrowLeft = useCallback(() => { - if (!doGrow || !growLeftRef.current) return - - const amount = Number(growLeftRef.current.value) - if (isNaN(amount)) return - - doGrow('left', amount) - }, [doGrow]) - const doGrowRight = useCallback(() => { - if (!doGrow || !growRightRef.current) return - - const amount = Number(growRightRef.current.value) - if (isNaN(amount)) return - - doGrow('right', amount) - }, [doGrow]) - const doGrowTop = useCallback(() => { - if (!doGrow || !growTopRef.current) return - - const amount = Number(growTopRef.current.value) - if (isNaN(amount)) return - - doGrow('top', amount) - }, [doGrow]) - const doGrowBottom = useCallback(() => { - if (!doGrow || !growBottomRef.current) return - - const amount = Number(growBottomRef.current.value) - if (isNaN(amount)) return - - doGrow('bottom', amount) - }, [doGrow]) - - window.doGrow = doGrow - - const gridCanvasStyle = useMemo( - () => ({ - width: Math.max(countColumns * tileSize, windowSize.width) + growWidth * 2, - height: Math.max(countRows * tileSize, windowSize.height) + growHeight * 2, - }), - [countColumns, countRows, tileSize, windowSize, growWidth, growHeight] - ) - - return ( -
-
- {doGrow && ( - <> -
-
- Add - -   columns -
-
-
-
- Add - -   columns -
-
-
-
- Add - -   rows -
-
-
-
- Add - -   rows -
-
- - )} - - {visibleButtons} -
-
- ) -}) - -export const PrimaryButtonGridIcon = memo(function PrimaryButtonGridIcon({ ...props }) { - const socket = useContext(SocketContext) - - const [{ isOver, canDrop }, drop] = useDrop({ - accept: 'preset', - drop: (dropData) => { - console.log('preset drop', dropData) - const location = { pageNumber: props.pageNumber, column: props.column, row: props.row } - socketEmitPromise(socket, 'presets:import-to-location', [ - dropData.connectionId, - dropData.presetId, - location, - ]).catch((e) => { - console.error('Preset import failed') - }) - }, - collect: (monitor) => ({ - isOver: !!monitor.isOver(), - canDrop: !!monitor.canDrop(), - }), - }) - - return -}) - -export const ButtonGridIcon = memo(function ButtonGridIcon({ ...props }) { - const { image, isUsed } = useButtonRenderCache({ - pageNumber: Number(props.pageNumber), - column: props.column, - row: props.row, - }) - - return -}) - -export const ButtonGridIconBase = memo(function ButtonGridIcon({ - pageNumber, - column, - row, - image, - left, - top, - style, - ...props -}) { - const location = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) - - const title = formatLocation(location) - return ( - - ) -}) diff --git a/webui/src/Buttons/ButtonInfiniteGrid.tsx b/webui/src/Buttons/ButtonInfiniteGrid.tsx new file mode 100644 index 0000000000..0c54c10eab --- /dev/null +++ b/webui/src/Buttons/ButtonInfiniteGrid.tsx @@ -0,0 +1,317 @@ +import { formatLocation } from '@companion/shared/ControlId' +import { ButtonPreview } from '../Components/ButtonPreview' +import React, { + forwardRef, + memo, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { useDrop } from 'react-dnd' +import { SocketContext, socketEmitPromise } from '../util' +import classNames from 'classnames' +import useScrollPosition from '../Hooks/useScrollPosition' +import useElementInnerSize from '../Hooks/useElementInnerSize' +import { useButtonRenderCache } from '../Hooks/useSharedRenderCache' +import { CButton, CInput } from '@coreui/react' +import { ControlLocation } from '@companion/shared/Model/Common' +import { UserConfigGridSize } from '@companion/shared/Model/UserConfigModel' +import { PresetDragItem } from './Presets' + +export interface ButtonInfiniteGridRef { + resetPosition(): void +} + +export interface ButtonInfiniteGridButtonProps { + pageNumber: number + column: number + row: number + + image: string | null + left: number + top: number + style: React.CSSProperties +} + +interface ButtonInfiniteGridProps { + isHot?: boolean + pageNumber: number + buttonClick?: (location: ControlLocation, pressed: boolean) => void + selectedButton?: ControlLocation | null + gridSize: UserConfigGridSize + doGrow?: (direction: 'left' | 'right' | 'top' | 'bottom', amount: number) => void + buttonIconFactory: React.ClassType +} + +export const ButtonInfiniteGrid = forwardRef( + function ButtonInfiniteGrid( + { isHot, pageNumber, buttonClick, selectedButton, gridSize, doGrow, buttonIconFactory }, + ref + ) { + const { minColumn, maxColumn, minRow, maxRow } = gridSize + const countColumns = maxColumn - minColumn + 1 + const countRows = maxRow - minRow + 1 + + const tileSize = 84 + const growWidth = doGrow ? 90 : 0 + const growHeight = doGrow ? 60 : 0 + + const [setSizeElement, windowSize] = useElementInnerSize() + const { scrollX, scrollY, setRef: setScrollRef } = useScrollPosition() + + // Reposition the window to have 0/0 in the top left + const [scrollerRef, setScrollerRef] = useState(null) + const resetScrollPosition = useCallback(() => { + if (scrollerRef) { + scrollerRef.scrollTop = -minRow * tileSize + growHeight + scrollerRef.scrollLeft = -minColumn * tileSize + growWidth + } + }, [scrollerRef, minColumn, minRow, tileSize, growWidth, growHeight]) + + const setRef = useCallback( + (ref: HTMLDivElement) => { + setSizeElement(ref) + setScrollRef(ref) + + setScrollerRef(ref) + }, + [setSizeElement, setScrollRef] + ) + + // Reset the position when the element changes + useEffect(() => resetScrollPosition(), [scrollerRef]) + + // Expose reload to the parent + useImperativeHandle( + ref, + () => ({ + resetPosition() { + resetScrollPosition() + }, + }), + [resetScrollPosition] + ) + + const visibleColumns = windowSize.width / tileSize + const visibleRows = windowSize.height / tileSize + + // Calculate the extents of what is visible + const scrollColumn = scrollX / tileSize + const scrollRow = scrollY / tileSize + const visibleMinX = minColumn + scrollColumn + const visibleMaxX = visibleMinX + visibleColumns + const visibleMinY = minRow + scrollRow + const visibleMaxY = visibleMinY + visibleRows + + // Calculate the bounds of what to draw in the DOM + // Include some spill to make scrolling smoother, but not too much to avoid being a performance drain + const drawMinColumn = Math.max(Math.floor(visibleMinX - visibleColumns / 2), minColumn) + const drawMaxColumn = Math.min(Math.ceil(visibleMaxX + visibleColumns / 2), maxColumn) + const drawMinRow = Math.max(Math.floor(visibleMinY - visibleRows / 2), minRow) + const drawMaxRow = Math.min(Math.ceil(visibleMaxY + visibleRows / 2), maxRow) + + const visibleButtons: JSX.Element[] = [] + for (let row = drawMinRow; row <= drawMaxRow; row++) { + for (let column = drawMinColumn; column <= drawMaxColumn; column++) { + visibleButtons.push( + React.createElement(buttonIconFactory, { + key: `${column}_${row}`, + + fixedSize: true, + row, + column, + pageNumber, + onClick: buttonClick, + selected: + selectedButton?.pageNumber === pageNumber && + selectedButton?.column === column && + selectedButton?.row === row, + left: (column - minColumn) * tileSize + growWidth, + top: (row - minRow) * tileSize + growHeight, + }) + ) + } + } + + const growTopRef = useRef(null) + const growBottomRef = useRef(null) + const growLeftRef = useRef(null) + const growRightRef = useRef(null) + + const doGrowLeft = useCallback(() => { + if (!doGrow || !growLeftRef.current) return + + const amount = Number(growLeftRef.current.value) + if (isNaN(amount)) return + + doGrow('left', amount) + }, [doGrow]) + const doGrowRight = useCallback(() => { + if (!doGrow || !growRightRef.current) return + + const amount = Number(growRightRef.current.value) + if (isNaN(amount)) return + + doGrow('right', amount) + }, [doGrow]) + const doGrowTop = useCallback(() => { + if (!doGrow || !growTopRef.current) return + + const amount = Number(growTopRef.current.value) + if (isNaN(amount)) return + + doGrow('top', amount) + }, [doGrow]) + const doGrowBottom = useCallback(() => { + if (!doGrow || !growBottomRef.current) return + + const amount = Number(growBottomRef.current.value) + if (isNaN(amount)) return + + doGrow('bottom', amount) + }, [doGrow]) + + const gridCanvasStyle = useMemo( + () => ({ + width: Math.max(countColumns * tileSize, windowSize.width) + growWidth * 2, + height: Math.max(countRows * tileSize, windowSize.height) + growHeight * 2, + }), + [countColumns, countRows, tileSize, windowSize, growWidth, growHeight] + ) + + return ( +
+
+ {doGrow && ( + <> +
+
+ Add + +   columns +
+
+
+
+ Add + +   columns +
+
+
+
+ Add + +   rows +
+
+
+
+ Add + +   rows +
+
+ + )} + + {visibleButtons} +
+
+ ) + } +) + +interface PresetDragState { + isOver: boolean + canDrop: boolean +} + +export const PrimaryButtonGridIcon = memo(function PrimaryButtonGridIcon({ ...props }: ButtonInfiniteGridButtonProps) { + const socket = useContext(SocketContext) + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: 'preset', + drop: (dropData) => { + console.log('preset drop', dropData) + const location = { pageNumber: props.pageNumber, column: props.column, row: props.row } + socketEmitPromise(socket, 'presets:import-to-location', [ + dropData.connectionId, + dropData.presetId, + location, + ]).catch(() => { + console.error('Preset import failed') + }) + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), + }) + + return +}) + +interface ButtonGridIconProps extends ButtonGridIconBaseProps {} + +export const ButtonGridIcon = memo(function ButtonGridIcon({ ...props }: ButtonGridIconProps) { + const { image, isUsed } = useButtonRenderCache({ + pageNumber: Number(props.pageNumber), + column: props.column, + row: props.row, + }) + + return +}) + +interface ButtonGridIconBaseProps { + pageNumber: number + column: number + row: number + image: string | null + left: number + top: number + style: React.CSSProperties + + dropRef?: React.RefCallback + dropHover?: boolean + canDrop?: boolean +} + +export const ButtonGridIconBase = memo(function ButtonGridIcon({ + pageNumber, + column, + row, + image, + left, + top, + style, + ...props +}: ButtonGridIconBaseProps) { + const location: ControlLocation = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) + + const title = formatLocation(location) + return ( + + ) +}) diff --git a/webui/src/Buttons/CustomVariablesList.jsx b/webui/src/Buttons/CustomVariablesList.tsx similarity index 82% rename from webui/src/Buttons/CustomVariablesList.jsx rename to webui/src/Buttons/CustomVariablesList.tsx index 78b24b8268..e81cd42dce 100644 --- a/webui/src/Buttons/CustomVariablesList.jsx +++ b/webui/src/Buttons/CustomVariablesList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { FormEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { CAlert, CButton, @@ -29,21 +29,31 @@ import { } from '@fortawesome/free-solid-svg-icons' import { TextInputField } from '../Components/TextInputField' import { CheckboxInputField } from '../Components/CheckboxInputField' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { isCustomVariableValid } from '@companion/shared/CustomVariable' import { useDrag, useDrop } from 'react-dnd' import { usePanelCollapseHelper } from '../Helpers/CollapseHelper' +import type { CompanionVariableValues } from '@companion-module/base' +import { CustomVariablesModel, CustomVariableDefinition } from '@companion/shared/Model/CustomVariableModel' const DRAG_ID = 'custom-variables' -export function CustomVariablesList({ setShowCustom }) { +interface CustomVariableDefinitionExt extends CustomVariableDefinition { + name: string +} + +interface CustomVariablesListProps { + setShowCustom: (show: boolean) => void +} + +export function CustomVariablesList({ setShowCustom }: CustomVariablesListProps) { const doBack = useCallback(() => setShowCustom(false), [setShowCustom]) const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const customVariableContext = useContext(CustomVariableDefinitionsContext) - const [variableValues, setVariableValues] = useState({}) + const [variableValues, setVariableValues] = useState({}) useEffect(() => { const doPoll = () => { @@ -67,13 +77,13 @@ export function CustomVariablesList({ setShowCustom }) { }, [socket]) const onCopied = useCallback(() => { - notifier.current.show(`Copied`, 'Copied to clipboard', 5000) + notifier.current?.show(`Copied`, 'Copied to clipboard', 5000) }, [notifier]) const [newName, setNewName] = useState('') const doCreateNew = useCallback( - (e) => { + (e: FormEvent) => { e?.preventDefault() if (isCustomVariableValid(newName)) { @@ -81,7 +91,7 @@ export function CustomVariablesList({ setShowCustom }) { .then((res) => { console.log('done with', res) if (res) { - notifier.current.show(`Failed to create variable`, res, 5000) + notifier.current?.show(`Failed to create variable`, res, 5000) } // clear value @@ -89,7 +99,7 @@ export function CustomVariablesList({ setShowCustom }) { }) .catch((e) => { console.error('Failed to create variable') - notifier.current.show(`Failed to create variable`, e?.toString?.() ?? e ?? 'Failed', 5000) + notifier.current?.show(`Failed to create variable`, e?.toString?.() ?? e ?? 'Failed', 5000) }) } }, @@ -97,16 +107,16 @@ export function CustomVariablesList({ setShowCustom }) { ) const setStartupValue = useCallback( - (name, value) => { - socketEmitPromise(socket, 'custom-variables:set-default', [name, value]).catch((e) => { + (name: string, value: any) => { + socketEmitPromise(socket, 'custom-variables:set-default', [name, value]).catch(() => { console.error('Failed to update variable') }) }, [socket] ) const setCurrentValue = useCallback( - (name, value) => { - socketEmitPromise(socket, 'custom-variables:set-current', [name, value]).catch((e) => { + (name: string, value: any) => { + socketEmitPromise(socket, 'custom-variables:set-current', [name, value]).catch(() => { console.error('Failed to update variable') }) }, @@ -114,23 +124,23 @@ export function CustomVariablesList({ setShowCustom }) { ) const setPersistenceValue = useCallback( - (name, value) => { - socketEmitPromise(socket, 'custom-variables:set-persistence', [name, value]).catch((e) => { + (name: string, value: boolean) => { + socketEmitPromise(socket, 'custom-variables:set-persistence', [name, value]).catch(() => { console.error('Failed to update variable') }) }, [socket] ) - const confirmRef = useRef(null) + const confirmRef = useRef(null) const doDelete = useCallback( - (name) => { - confirmRef.current.show( + (name: string) => { + confirmRef.current?.show( 'Delete variable', `Are you sure you want to delete the custom variable "${name}"?`, 'Delete', () => { - socketEmitPromise(socket, 'custom-variables:delete', [name]).catch((e) => { + socketEmitPromise(socket, 'custom-variables:delete', [name]).catch(() => { console.error('Failed to delete variable') }) } @@ -139,13 +149,13 @@ export function CustomVariablesList({ setShowCustom }) { [socket] ) - const customVariablesRef = useRef(null) + const customVariablesRef = useRef() useEffect(() => { customVariablesRef.current = customVariableContext }, [customVariableContext]) const moveRow = useCallback( - (itemName, targetName) => { + (itemName: string, targetName: string) => { if (customVariablesRef.current) { const rawNames = Object.entries(customVariablesRef.current) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) @@ -175,7 +185,7 @@ export function CustomVariablesList({ setShowCustom }) { const updateFilter = useCallback((e) => setFilter(e.currentTarget.value), []) const variableDefinitions = useMemo(() => { - const defs = [] + const defs: CustomVariableDefinitionExt[] = [] for (const [name, variable] of Object.entries(customVariableContext || {})) { defs.push({ ...variable, @@ -190,7 +200,7 @@ export function CustomVariablesList({ setShowCustom }) { const hasNoVariables = variableDefinitions.length === 0 const [candidates, errorMsg] = useMemo(() => { - let candidates = [] + let candidates: CustomVariableDefinitionExt[] = [] try { if (!filter) { candidates = variableDefinitions @@ -308,6 +318,30 @@ export function CustomVariablesList({ setShowCustom }) { ) } +interface CustomVariableDragItem { + index: number + name: string +} +interface CustomVariableDragStatus { + isDragging: boolean +} + +interface CustomVariableRowProps { + index: number + name: string + shortname: string + value: any + info: CustomVariableDefinitionExt + onCopied: () => void + doDelete: (name: string) => void + setStartupValue: (name: string, value: any) => void + setCurrentValue: (name: string, value: any) => void + setPersistenceValue: (name: string, persisted: boolean) => void + moveRow: (itemName: string, targetName: string) => void + isCollapsed: boolean + setCollapsed: (name: string, collapsed: boolean) => void +} + function CustomVariableRow({ index, name, @@ -322,16 +356,16 @@ function CustomVariableRow({ moveRow, isCollapsed, setCollapsed, -}) { +}: CustomVariableRowProps) { const fullname = `internal:${shortname}` const doCollapse = useCallback(() => setCollapsed(name, true), [setCollapsed, name]) const doExpand = useCallback(() => setCollapsed(name, false), [setCollapsed, name]) const ref = useRef(null) - const [, drop] = useDrop({ + const [, drop] = useDrop({ accept: DRAG_ID, - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -352,12 +386,12 @@ function CustomVariableRow({ moveRow(item.name, name) }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: DRAG_ID, canDrag: true, item: { name: name, - // index: index, + index: index, // ref: ref, }, collect: (monitor) => ({ @@ -421,7 +455,7 @@ function CustomVariableRow({ Startup value: setStartupValue(name, val)} /> diff --git a/webui/src/Buttons/EditButton.jsx b/webui/src/Buttons/EditButton.tsx similarity index 68% rename from webui/src/Buttons/EditButton.jsx rename to webui/src/Buttons/EditButton.tsx index aab128089b..4d4caa3ca6 100644 --- a/webui/src/Buttons/EditButton.jsx +++ b/webui/src/Buttons/EditButton.tsx @@ -40,10 +40,11 @@ import React, { useState, useMemo, memo, + FormEvent, } from 'react' import { nanoid } from 'nanoid' -import { ButtonPreview } from '../Components/ButtonPreview' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { ButtonPreviewBase } from '../Components/ButtonPreview' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { KeyReceiver, LoadingRetryOrError, @@ -53,7 +54,7 @@ import { PagesContext, } from '../util' import { ControlActionSetEditor } from '../Controls/ActionSetEditor' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { ButtonStyleConfig } from '../Controls/ButtonStyleConfig' import { ControlOptionsEditor } from '../Controls/ControlOptionsEditor' import { ControlFeedbacksEditor } from '../Controls/FeedbackEditor' @@ -62,23 +63,32 @@ import { useElementSize } from 'usehooks-ts' import { GetStepIds } from '@companion/shared/Controls' import CSwitch from '../CSwitch' import { formatLocation } from '@companion/shared/ControlId' +import { ControlLocation } from '@companion/shared/Model/Common' +import { ActionInstance, ActionSetsModel, ActionStepOptions } from '@companion/shared/Model/ActionModel' +import { FeedbackInstance } from '@companion/shared/Model/FeedbackModel' +import { NormalButtonSteps, SomeButtonModel } from '@companion/shared/Model/ButtonModel' + +interface EditButtonProps { + location: ControlLocation + onKeyUp: (e: React.KeyboardEvent) => void +} -export const EditButton = memo(function EditButton({ location, onKeyUp, contentHeight }) { +export const EditButton = memo(function EditButton({ location, onKeyUp }: EditButtonProps) { const socket = useContext(SocketContext) const pages = useContext(PagesContext) const controlId = pages?.[location.pageNumber]?.controls?.[location.row]?.[location.column] - const resetModalRef = useRef() + const resetModalRef = useRef(null) - const [previewImage, setPreviewImage] = useState(null) - const [config, setConfig] = useState(null) - const [runtimeProps, setRuntimeProps] = useState(null) + const [previewImage, setPreviewImage] = useState(null) + const [config, setConfig] = useState(null) + const [runtimeProps, setRuntimeProps] = useState | null | false>(null) - const configRef = useRef() - configRef.current = config // update the ref every render + const configRef = useRef() + configRef.current = config || undefined // update the ref every render - const [configError, setConfigError] = useState(null) + const [configError, setConfigError] = useState(null) const [reloadConfigToken, setReloadConfigToken] = useState(nanoid()) @@ -100,22 +110,24 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH setConfigError('Failed to load control config') }) - const patchConfig = (patch) => { + const patchConfig = (patch: JsonPatchOperation[] | false) => { setConfig((oldConfig) => { + if (!oldConfig) return oldConfig if (patch === false) { return false } else { - return jsonPatch.applyPatch(cloneDeep(oldConfig) || {}, patch).newDocument + return jsonPatch.applyPatch(cloneDeep(oldConfig), patch).newDocument } }) } - const patchRuntimeProps = (patch) => { + const patchRuntimeProps = (patch: JsonPatchOperation[] | false) => { setRuntimeProps((oldProps) => { + if (!oldProps) return oldProps if (patch === false) { return {} } else { - return jsonPatch.applyPatch(cloneDeep(oldProps) || {}, patch).newDocument + return jsonPatch.applyPatch(cloneDeep(oldProps), patch).newDocument } }) } @@ -123,7 +135,7 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH socket.on(`controls:config-${controlId}`, patchConfig) socket.on(`controls:runtime-${controlId}`, patchRuntimeProps) - const updateImage = (img) => { + const updateImage = (img: string | null) => { setPreviewImage(img) } socket.on(`controls:preview-${controlId}`, updateImage) @@ -140,7 +152,7 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH }, [socket, controlId, reloadConfigToken]) const setButtonType = useCallback( - (newType) => { + (newType: string) => { let show_warning = false const currentType = configRef.current?.type @@ -162,7 +174,7 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH } if (show_warning) { - resetModalRef.current.show( + resetModalRef.current?.show( `Change style`, `Changing to this button style will erase actions and feedbacks configured for this button - continue?`, 'OK', @@ -179,7 +191,7 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH const doRetryLoad = useCallback(() => setReloadConfigToken(nanoid()), []) const clearButton = useCallback(() => { - resetModalRef.current.show( + resetModalRef.current?.show( `Clear button ${formatLocation(location)}`, `This will clear the style, feedbacks and all actions`, 'Clear', @@ -212,16 +224,16 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH ) }, [socket, location]) - const errors = [] + const errors: string[] = [] if (configError) errors.push(configError) const loadError = errors.length > 0 ? errors.join(', ') : null - const hasConfig = config || config === false - const hasRuntimeProps = runtimeProps || runtimeProps === false + const hasConfig = !!config || config === false + const hasRuntimeProps = !!runtimeProps || runtimeProps === false const dataReady = !loadError && hasConfig && hasRuntimeProps // Tip: This query needs to match the page layout. It doesn't need to be reactive, as the useElementSize will force a re-render - const isTwoColumn = window.matchMedia('(min-width: 1200px)').matches - const [, { height: hintHeight }] = useElementSize() + // const isTwoColumn = window.matchMedia('(min-width: 1200px)').matches + // const [, { height: hintHeight }] = useElementSize() return ( @@ -229,10 +241,11 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH {hasConfig && dataReady && ( <> - - <> - - {config.type === undefined && ( + + {!config || + (config.type === undefined && ( + + {' '} {/* This could be simplified to use the split property on CDropdownToggle, but then onClick doesnt work https://github.com/coreui/coreui-react/issues/179 */} @@ -255,91 +268,100 @@ export const EditButton = memo(function EditButton({ location, onKeyUp, contentH setButtonType('pagedown')}>Page down - )} -   - -   - - - {' '} -   - {config?.options?.rotaryActions && ( - <> - - - -   - - - - - )} - - + + ))} +   - - -
- -
+ +   + + + {' '}
- {config && runtimeProps && ( +   + {config && 'options' in config && config?.options?.rotaryActions && ( - + + + +   + + + )} + {controlId && config && config.type === 'button' && ( + <> + + + +
+ +
+
+ {runtimeProps && ( + + + + )} + + )} )}
) }) -function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryActions, feedbacks }) { +interface TabsSectionProps { + style: 'button' | 'pageup' | 'pagenum' | 'pagedown' + controlId: string + location: ControlLocation + steps: NormalButtonSteps + runtimeProps: Record + rotaryActions: boolean + feedbacks: FeedbackInstance[] +} + +function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryActions, feedbacks }: TabsSectionProps) { const socket = useContext(SocketContext) - const confirmRef = useRef() + const confirmRef = useRef(null) const tabsScrollRef = useRef(null) const [tabsSizeRef] = useElementSize() @@ -352,7 +374,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc [tabsSizeRef] ) - const clickSelectedStep = useCallback((newStep) => { + const clickSelectedStep = useCallback((newStep: string) => { setSelectedStep(newStep) // Let's reactivate this again if users start setting cars on fire because I removed it. -wv @@ -380,7 +402,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc }, [keys, selectedStep]) const appendStep = useCallback( - (e) => { + (e: FormEvent) => { if (e) e.preventDefault() socketEmitPromise(socket, 'controls:step:add', [controlId]) @@ -397,8 +419,8 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc [socket, controlId] ) const removeStep = useCallback( - (stepId) => { - confirmRef.current.show('Remove step', 'Are you sure you wish to remove this step?', 'Remove', () => { + (stepId: string) => { + confirmRef.current?.show('Remove step', 'Are you sure you wish to remove this step?', 'Remove', () => { socketEmitPromise(socket, 'controls:step:remove', [controlId, stepId]).catch((e) => { console.error('Failed to delete step:', e) }) @@ -407,7 +429,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc [socket, controlId] ) const swapSteps = useCallback( - (stepId1, stepId2) => { + (stepId1: string, stepId2: string) => { socketEmitPromise(socket, 'controls:step:swap', [controlId, stepId1, stepId2]) .then(() => { setSelectedStep(`step:${stepId2}`) @@ -419,7 +441,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc [socket, controlId] ) const setCurrentStep = useCallback( - (stepId) => { + (stepId: string) => { socketEmitPromise(socket, 'controls:step:set-current', [controlId, stepId]).catch((e) => { console.error('Failed to set step:', e) }) @@ -428,7 +450,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc ) const appendSet = useCallback( - (stepId) => { + (stepId: string) => { socketEmitPromise(socket, 'controls:action-set:add', [controlId, stepId]).catch((e) => { console.error('Failed to append set:', e) }) @@ -436,8 +458,8 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc [socket, controlId] ) const removeSet = useCallback( - (stepId, setId) => { - confirmRef.current.show('Remove step', 'Are you sure you wish to remove this group?', 'Remove', () => { + (stepId: string, setId: string | number) => { + confirmRef.current?.show('Remove set', 'Are you sure you wish to remove this group?', 'Remove', () => { socketEmitPromise(socket, 'controls:action-set:remove', [controlId, stepId, setId]).catch((e) => { console.error('Failed to delete set:', e) }) @@ -449,7 +471,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc if (style === 'button') { const selectedIndex = keys.findIndex((k) => `step:${k}` === selectedStep) const selectedKey = selectedIndex >= 0 && keys[selectedIndex] - const selectedStep2 = selectedKey && steps[selectedKey] + const selectedStep2 = selectedKey ? steps[selectedKey] : undefined return (
@@ -460,29 +482,29 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc
- {keys.map((k, i) => ( - - { - // if there's more than one step, we need to show the current step - const moreThanOneStep = keys.length > 1 - // the current step is the one that is currently being executed - const isCurrent = runtimeProps.current_step_id === k - // both selected and the current step - const isActiveAndCurrent = k === selectedIndex && runtimeProps.current_step_id === k - - if (moreThanOneStep) { - if (isActiveAndCurrent) return 'selected-and-active' - if (isCurrent) return 'only-current' - } - })()} - style={{}} - > - {i === 0 ? (keys.length > 1 ? 'Step ' + (i + 1) : 'Actions') : i + 1} - - - ))} + {keys.map((k: string | number, i) => { + let linkClassname: string | undefined = undefined + + // if there's more than one step, we need to show the current step + const moreThanOneStep = keys.length > 1 + // the current step is the one that is currently being executed + const isCurrent = runtimeProps.current_step_id === k + // both selected and the current step + const isActiveAndCurrent = k === selectedIndex && runtimeProps.current_step_id === k + + if (moreThanOneStep) { + if (isActiveAndCurrent) linkClassname = 'selected-and-active' + else if (isCurrent) linkClassname = 'only-current' + } + + return ( + + + {i === 0 ? (keys.length > 1 ? 'Step ' + (i + 1) : 'Actions') : i + 1} + + + ) + })} Feedbacks @@ -515,6 +537,8 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc controlId={controlId} feedbacks={feedbacks} location={location} + booleanOnly={false} + addPlaceholder="+ Add feedback" /> )} @@ -551,7 +575,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc style={{ backgroundColor: '#f0f0f0', marginRight: 1 }} title="Add step" disabled={keys.length === 1} - onClick={() => appendStep()} + onClick={appendStep} > @@ -565,7 +589,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc - {rotaryActions && ( + {rotaryActions && selectedStep2 && ( <> )} - - - + {selectedStep2 && ( + <> + + + - + + + )}

@@ -630,17 +658,33 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc } } -function EditActionsRelease({ controlId, location, action_sets, stepOptions, stepId, removeSet }) { +interface EditActionsReleaseProps { + controlId: string + location: ControlLocation + action_sets: ActionSetsModel + stepOptions: ActionStepOptions + stepId: string + removeSet: (stepId: string, setId: string | number) => void +} + +function EditActionsRelease({ + controlId, + location, + action_sets, + stepOptions, + stepId, + removeSet, +}: EditActionsReleaseProps) { const socket = useContext(SocketContext) - const editRef = useRef(null) + const editRef = useRef(null) const configureSet = useCallback( - (oldId) => { + (oldId: string | number) => { if (editRef.current) { console.log(stepOptions, oldId) const runWhileHeld = stepOptions.runWhileHeld.includes(Number(oldId)) - editRef.current.show(Number(oldId), runWhileHeld, (newId, runWhileHeld) => { + editRef.current?.show(Number(oldId), runWhileHeld, (newId: number, runWhileHeld: boolean) => { if (!isNaN(newId)) { socketEmitPromise(socket, 'controls:action-set:rename', [controlId, stepId, oldId, newId]) .then(() => { @@ -663,8 +707,10 @@ function EditActionsRelease({ controlId, location, action_sets, stepOptions, ste [socket, controlId, stepId, stepOptions] ) - const candidate_sets = Object.entries(action_sets).filter(([id]) => !isNaN(id)) - candidate_sets.sort((a, b) => Number(a[0]) - Number(b[0])) + const candidate_sets = Object.entries(action_sets) + .map((o): [number, ActionInstance[] | undefined] => [Number(o[0]), o[1]]) + .filter(([id]) => !isNaN(id)) + candidate_sets.sort((a, b) => a[0] - b[0]) const components = candidate_sets.map(([id, actions]) => { const runWhileHeld = stepOptions.runWhileHeld.includes(Number(id)) @@ -714,25 +760,36 @@ function EditActionsRelease({ controlId, location, action_sets, stepOptions, ste ) } -const EditDurationGroupPropertiesModal = forwardRef(function EditDurationGroupPropertiesModal(props, ref) { - const [data, setData] = useState(null) +type EditDurationCompleteCallback = (duration: number, whileHeld: boolean) => void + +interface EditDurationGroupPropertiesModalRef { + show(duration: number, whileHeld: boolean, completeCallback: EditDurationCompleteCallback): void +} + +interface EditDurationGroupPropertiesModalProps { + // Nothing +} + +const EditDurationGroupPropertiesModal = forwardRef< + EditDurationGroupPropertiesModalRef, + EditDurationGroupPropertiesModalProps +>(function EditDurationGroupPropertiesModal(_props, ref) { + const [data, setData] = useState<[number, EditDurationCompleteCallback] | null>(null) const [show, setShow] = useState(false) - const [newDurationValue, setNewDurationValue] = useState(null) - const [newWhileHeldValue, setNewWhileHeldValue] = useState(null) + const [newDurationValue, setNewDurationValue] = useState(null) + const [newWhileHeldValue, setNewWhileHeldValue] = useState(null) - const buttonRef = useRef() + const buttonRef = useRef(null) const buttonFocus = () => { - if (buttonRef.current) { - buttonRef.current.focus() - } + buttonRef.current?.focus() } const doClose = useCallback(() => setShow(false), []) const onClosed = useCallback(() => setData(null), []) const doAction = useCallback( - (e) => { + (e: FormEvent) => { if (e) e.preventDefault() setData(null) @@ -742,6 +799,7 @@ const EditDurationGroupPropertiesModal = forwardRef(function EditDurationGroupPr // completion callback const cb = data?.[1] + if (!cb || newDurationValue === null || newWhileHeldValue === null) return cb(newDurationValue, newWhileHeldValue) }, [data, newDurationValue, newWhileHeldValue] diff --git a/webui/src/Buttons/Presets.jsx b/webui/src/Buttons/Presets.tsx similarity index 63% rename from webui/src/Buttons/Presets.jsx rename to webui/src/Buttons/Presets.tsx index 917efbedfc..3515571d60 100644 --- a/webui/src/Buttons/Presets.jsx +++ b/webui/src/Buttons/Presets.tsx @@ -9,17 +9,28 @@ import { ModulesContext, } from '../util' import { useDrag } from 'react-dnd' -import { ButtonPreview, RedImage } from '../Components/ButtonPreview' +import { ButtonPreviewBase, RedImage } from '../Components/ButtonPreview' import { nanoid } from 'nanoid' +import type { ClientConnectionConfig, ModuleDisplayInfo } from '@companion/shared/Model/Common' +import type { UIPresetDefinition } from '@companion/shared/Model/Presets' +import { Operation as JsonPatchOperation } from 'fast-json-patch' -export const InstancePresets = function InstancePresets({ resetToken }) { +interface InstancePresetsProps { + resetToken: string +} + +export const InstancePresets = function InstancePresets({ resetToken }: InstancePresetsProps) { const socket = useContext(SocketContext) const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) - const [connectionAndCategory, setConnectionAndCategory] = useState([null, null]) - const [presetsMap, setPresetsMap] = useState(null) - const [presetsError, setPresetError] = useState(null) + const [connectionAndCategory, setConnectionAndCategory] = useState< + [connectionId: string | null, category: string | null] + >([null, null]) + const [presetsMap, setPresetsMap] = useState | undefined> | null>( + null + ) + const [presetsError, setPresetError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doRetryPresetsLoad = useCallback(() => setReloadToken(nanoid()), []) @@ -38,12 +49,16 @@ export const InstancePresets = function InstancePresets({ resetToken }) { setPresetsMap(data) }) .catch((e) => { - console.error('Failed to load presets') + console.error('Failed to load presets', e) setPresetError('Failed to load presets') }) - const updatePresets = (id, patch) => { - setPresetsMap((oldPresets) => applyPatchOrReplaceSubObject(oldPresets, id, patch, [])) + const updatePresets = (id: string, patch: JsonPatchOperation[]) => { + setPresetsMap((oldPresets) => + oldPresets + ? applyPatchOrReplaceSubObject | undefined>(oldPresets, id, patch, {}) + : null + ) } socket.on('presets:update', updatePresets) @@ -51,7 +66,7 @@ export const InstancePresets = function InstancePresets({ resetToken }) { return () => { socket.off('presets:update', updatePresets) - socketEmitPromise(socket, 'presets:unsubscribe', []).catch((e) => { + socketEmitPromise(socket, 'presets:unsubscribe', []).catch(() => { console.error('Failed to unsubscribe to presets') }) } @@ -61,7 +76,7 @@ export const InstancePresets = function InstancePresets({ resetToken }) { // Show loading or an error return ( - + ) } @@ -70,7 +85,7 @@ export const InstancePresets = function InstancePresets({ resetToken }) { const connectionInfo = connectionsContext[connectionAndCategory[0]] const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined - const presets = presetsMap[connectionAndCategory[0]] ?? [] + const presets = presetsMap[connectionAndCategory[0]] ?? {} if (connectionAndCategory[1]) { return ( @@ -97,7 +112,12 @@ export const InstancePresets = function InstancePresets({ resetToken }) { } } -function PresetsConnectionList({ presets, setConnectionAndCategory }) { +interface PresetsConnectionListProps { + presets: Record | undefined> + setConnectionAndCategory: (info: [connectionId: string | null, category: string | null]) => void +} + +function PresetsConnectionList({ presets, setConnectionAndCategory }: PresetsConnectionListProps) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) @@ -139,8 +159,22 @@ function PresetsConnectionList({ presets, setConnectionAndCategory }) { ) } -function PresetsCategoryList({ presets, connectionInfo, moduleInfo, selectedConnectionId, setConnectionAndCategory }) { - const categories = new Set() +interface PresetsCategoryListProps { + presets: Record + connectionInfo: ClientConnectionConfig + moduleInfo: ModuleDisplayInfo | undefined + selectedConnectionId: string + setConnectionAndCategory: (info: [connectionId: string | null, category: string | null]) => void +} + +function PresetsCategoryList({ + presets, + connectionInfo, + moduleInfo, + selectedConnectionId, + setConnectionAndCategory, +}: PresetsCategoryListProps) { + const categories = new Set() for (const preset of Object.values(presets)) { categories.add(preset.category) } @@ -178,13 +212,25 @@ function PresetsCategoryList({ presets, connectionInfo, moduleInfo, selectedConn ) } -function PresetsButtonList({ presets, selectedConnectionId, selectedCategory, setConnectionAndCategory }) { +interface PresetsButtonListProps { + presets: Record + selectedConnectionId: string + selectedCategory: string + setConnectionAndCategory: (info: [connectionId: string | null, category: string | null]) => void +} + +function PresetsButtonList({ + presets, + selectedConnectionId, + selectedCategory, + setConnectionAndCategory, +}: PresetsButtonListProps) { const doBack = useCallback( () => setConnectionAndCategory([selectedConnectionId, null]), [setConnectionAndCategory, selectedConnectionId] ) - const options = Object.values(presets).filter((p) => p.category === selectedCategory) + const filteredPresets = Object.values(presets).filter((p) => p.category === selectedCategory) return (

@@ -196,15 +242,9 @@ function PresetsButtonList({ presets, selectedConnectionId, selectedCategory, se

Drag and drop the preset buttons below into your buttons-configuration.

- {options.map((preset, i) => { + {filteredPresets.map((preset, i) => { return ( - + ) })} @@ -213,42 +253,53 @@ function PresetsButtonList({ presets, selectedConnectionId, selectedCategory, se ) } -function PresetIconPreview({ preset, connectionId, ...childProps }) { +interface PresetIconPreviewProps { + connectionId: string + presetId: string + title: string +} + +function PresetIconPreview({ connectionId, presetId, title }: PresetIconPreviewProps) { const socket = useContext(SocketContext) const [previewImage, setPreviewImage] = useState(null) const [previewError, setPreviewError] = useState(false) const [retryToken, setRetryToken] = useState(nanoid()) - const [, drag] = useDrag({ + const [, drag] = useDrag({ type: 'preset', item: { connectionId: connectionId, - presetId: preset.id, + presetId: presetId, }, }) useEffect(() => { setPreviewError(false) - socketEmitPromise(socket, 'presets:preview_render', [connectionId, preset.id]) + socketEmitPromise(socket, 'presets:preview_render', [connectionId, presetId]) .then((img) => { setPreviewImage(img) }) - .catch((e) => { + .catch(() => { console.error('Failed to preview control') setPreviewError(true) }) - }, [preset.id, socket, connectionId, retryToken]) + }, [presetId, socket, connectionId, retryToken]) - const onClick = useCallback((_location, isDown) => isDown && setRetryToken(nanoid()), []) + const onClick = useCallback((isDown) => isDown && setRetryToken(nanoid()), []) return ( - ) } + +export interface PresetDragItem { + connectionId: string + presetId: string +} diff --git a/webui/src/Buttons/Variables.jsx b/webui/src/Buttons/Variables.tsx similarity index 76% rename from webui/src/Buttons/Variables.jsx rename to webui/src/Buttons/Variables.tsx index 1c53e9f905..4889481c85 100644 --- a/webui/src/Buttons/Variables.jsx +++ b/webui/src/Buttons/Variables.tsx @@ -4,14 +4,18 @@ import { ConnectionsContext, VariableDefinitionsContext, ModulesContext } from ' import { VariablesTable } from '../Components/VariablesTable' import { CustomVariablesList } from './CustomVariablesList' -export const ConnectionVariables = function ConnectionVariables({ resetToken }) { +interface ConnectionVariablesProps { + resetToken: string +} + +export const ConnectionVariables = function ConnectionVariables({ resetToken }: ConnectionVariablesProps) { const connectionsContext = useContext(ConnectionsContext) - const [connectionId, setConnectionId] = useState(null) + const [connectionId, setConnectionId] = useState(null) const [showCustom, setShowCustom] = useState(false) - const connectionsLabelMap = useMemo(() => { - const labelMap = new Map() + const connectionsLabelMap: ReadonlyMap = useMemo(() => { + const labelMap = new Map() for (const [connectionId, connectionInfo] of Object.entries(connectionsContext)) { labelMap.set(connectionInfo.label, connectionId) } @@ -41,7 +45,17 @@ export const ConnectionVariables = function ConnectionVariables({ resetToken }) } } -function VariablesConnectionList({ setConnectionId, setShowCustom, connectionsLabelMap }) { +interface VariablesConnectionListProps { + setConnectionId: (connectionId: string | null) => void + setShowCustom: (show: boolean) => void + connectionsLabelMap: ReadonlyMap +} + +function VariablesConnectionList({ + setConnectionId, + setShowCustom, + connectionsLabelMap, +}: VariablesConnectionListProps) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) const variableDefinitionsContext = useContext(VariableDefinitionsContext) @@ -65,7 +79,11 @@ function VariablesConnectionList({ setConnectionId, setShowCustom, connectionsLa return (
- setConnectionId(connectionId)}> + setConnectionId(connectionId ?? null)} + > {moduleInfo?.name ?? moduleInfo?.name ?? '?'} ({label ?? connectionId})
@@ -86,7 +104,12 @@ function VariablesConnectionList({ setConnectionId, setShowCustom, connectionsLa ) } -function VariablesList({ selectedConnectionLabel, setConnectionId }) { +interface VariablesListProps { + selectedConnectionLabel: string + setConnectionId: (connectionId: string | null) => void +} + +function VariablesList({ selectedConnectionLabel, setConnectionId }: VariablesListProps) { const doBack = useCallback(() => setConnectionId(null), [setConnectionId]) return ( diff --git a/webui/src/Buttons/index.jsx b/webui/src/Buttons/index.tsx similarity index 87% rename from webui/src/Buttons/index.jsx rename to webui/src/Buttons/index.tsx index da0df9d3d9..3af9de859a 100644 --- a/webui/src/Buttons/index.jsx +++ b/webui/src/Buttons/index.tsx @@ -7,23 +7,27 @@ import { SocketContext, MyErrorBoundary, socketEmitPromise, UserConfigContext } import { ButtonsGridPanel } from './ButtonGridPanel' import { EditButton } from './EditButton' import { ActionRecorder } from './ActionRecorder' -import { memo, useCallback, useContext, useRef, useState } from 'react' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import React, { memo, useCallback, useContext, useRef, useState } from 'react' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { ConnectionVariables } from './Variables' -import { useElementSize } from 'usehooks-ts' import { formatLocation } from '@companion/shared/ControlId' +import { ControlLocation } from '@companion/shared/Model/Common' -export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { +interface ButtonsPageProps { + hotPress: boolean +} + +export const ButtonsPage = memo(function ButtonsPage({ hotPress }: ButtonsPageProps) { const socket = useContext(SocketContext) const userConfig = useContext(UserConfigContext) - const clearModalRef = useRef() + const clearModalRef = useRef(null) const [tabResetToken, setTabResetToken] = useState(nanoid()) const [activeTab, setActiveTab] = useState('presets') - const [selectedButton, setSelectedButton] = useState(null) + const [selectedButton, setSelectedButton] = useState(null) const [pageNumber, setPageNumber] = useState(1) - const [copyFromButton, setCopyFromButton] = useState(null) + const [copyFromButton, setCopyFromButton] = useState<[ControlLocation, string] | null>(null) const doChangeTab = useCallback((newTab) => { setActiveTab((oldTab) => { @@ -61,7 +65,7 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { switch (e.key) { case 'ArrowDown': setSelectedButton((selectedButton) => { - if (selectedButton) { + if (selectedButton && userConfig?.gridSize) { return { ...selectedButton, row: @@ -69,13 +73,15 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ? userConfig.gridSize.minRow : selectedButton.row + 1, } + } else { + return selectedButton } }) // TODO - ensure kept in view break case 'ArrowUp': setSelectedButton((selectedButton) => { - if (selectedButton) { + if (selectedButton && userConfig?.gridSize) { return { ...selectedButton, row: @@ -83,13 +89,15 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ? userConfig.gridSize.maxRow : selectedButton.row - 1, } + } else { + return selectedButton } }) // TODO - ensure kept in view break case 'ArrowLeft': setSelectedButton((selectedButton) => { - if (selectedButton) { + if (selectedButton && userConfig?.gridSize) { return { ...selectedButton, column: @@ -97,13 +105,15 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ? userConfig.gridSize.maxColumn : selectedButton.column - 1, } + } else { + return selectedButton } }) // TODO - ensure kept in view break case 'ArrowRight': setSelectedButton((selectedButton) => { - if (selectedButton) { + if (selectedButton && userConfig?.gridSize) { return { ...selectedButton, column: @@ -111,6 +121,8 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ? userConfig.gridSize.minColumn : selectedButton.column + 1, } + } else { + return selectedButton } }) // TODO - ensure kept in view @@ -124,6 +136,8 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ...selectedButton, pageNumber: newPageNumber, } + } else { + return selectedButton } }) break @@ -136,6 +150,8 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { ...selectedButton, pageNumber: newPageNumber, } + } else { + return selectedButton } }) break @@ -145,7 +161,7 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { // keyup with button selected if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete')) { - clearModalRef.current.show( + clearModalRef.current?.show( `Clear button ${formatLocation(selectedButton)}`, `This will clear the style, feedbacks and all actions`, 'Clear', @@ -185,11 +201,9 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { } } }, - [socket, selectedButton, copyFromButton] + [socket, selectedButton, copyFromButton, userConfig?.gridSize] ) - const [contentRef, { height: contentHeight }] = useElementSize() - return ( @@ -234,13 +248,12 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { - + {selectedButton && ( diff --git a/webui/src/CSwitch.jsx b/webui/src/CSwitch.tsx similarity index 73% rename from webui/src/CSwitch.jsx rename to webui/src/CSwitch.tsx index eedfbe18f0..b1f1701206 100644 --- a/webui/src/CSwitch.jsx +++ b/webui/src/CSwitch.tsx @@ -1,9 +1,25 @@ -import React from 'react' +import React, { ChangeEvent } from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' +interface CSwitchProps { + className?: string + innerRef?: React.LegacyRef + size?: '' | 'lg' | 'sm' + shape?: '' | 'pill' | 'square' + variant?: '' | '3d' | 'opposite' | 'outline' + color?: string + labelOn?: string + labelOff?: string + title?: string + disabled?: boolean + checked: boolean + onChange: (e: ChangeEvent) => void + tooltip?: string +} + //component - CoreUI / CSwitch -const CSwitch = (props) => { +const CSwitch = (props: CSwitchProps) => { let { className, // diff --git a/webui/src/Cloud/RegionPanel.jsx b/webui/src/Cloud/RegionPanel.tsx similarity index 70% rename from webui/src/Cloud/RegionPanel.jsx rename to webui/src/Cloud/RegionPanel.tsx index 64bea0e941..d02d0409c3 100644 --- a/webui/src/Cloud/RegionPanel.jsx +++ b/webui/src/Cloud/RegionPanel.tsx @@ -1,19 +1,33 @@ import React, { Component } from 'react' import { CAlert, CListGroupItem, CSwitch } from '@coreui/react' +import type { Socket } from 'socket.io-client' // The cloud part is written in old fashioned Class-components // because even if the hipsters say it's slow and retarted, i think it's prettier. -const onlineServerStyle = { color: 'green' } +const onlineServerStyle: React.CSSProperties = { color: 'green' } -export class CloudRegionPanel extends Component { - constructor(props) { +interface CloudRegionPanelProps { + socket: Socket + id: string + disabled: boolean +} +interface CloudRegionPanelState { + connected: boolean + enabled: boolean + error: string | null + name: string + pingResults: number +} + +export class CloudRegionPanel extends Component { + constructor(props: CloudRegionPanelProps) { super(props) this.state = { connected: false, enabled: false, - error: '', + error: null, name: '', pingResults: -1, } @@ -33,24 +47,24 @@ export class CloudRegionPanel extends Component { this.props.socket.off('cloud_region_state', this.cloudStateDidUpdate) } - cloudStateDidUpdate(id, newState) { + private cloudStateDidUpdate(id: string, newState: CloudRegionPanelState) { if (id === this.props.id) { this.setState({ ...newState }) } } - cloudSetState(newState) { + private cloudSetState(newState: Partial) { if (!this.props.disabled) { this.props.socket.emit('cloud_region_state_set', this.props.id, newState) // Reset the error message if the user changes the enabled state if (newState.enabled !== undefined) { - this.setState({ error: '' }) + this.setState({ error: null }) } } } render() { - const styleText = { + const styleText: React.CSSProperties = { marginLeft: 6, marginTop: -10, display: 'inline-block', @@ -64,7 +78,7 @@ export class CloudRegionPanel extends Component { this.cloudSetState({ enabled: e.target.checked })} + onChange={(e) => this.cloudSetState({ enabled: e.currentTarget.checked })} disabled={this.props.disabled} width={100} />{' '} @@ -77,7 +91,7 @@ export class CloudRegionPanel extends Component { > {this.state.name} {this.state.pingResults > -1 ? `(${this.state.pingResults}ms)` : ''} - {this.state.enabled && this.state.error !== '' && ( + {this.state.enabled && this.state.error && ( {this.state.error} diff --git a/webui/src/Cloud/UserPass.jsx b/webui/src/Cloud/UserPass.tsx similarity index 67% rename from webui/src/Cloud/UserPass.jsx rename to webui/src/Cloud/UserPass.tsx index e7fdbb11a7..a112766931 100644 --- a/webui/src/Cloud/UserPass.jsx +++ b/webui/src/Cloud/UserPass.tsx @@ -1,11 +1,22 @@ -import React, { Component } from 'react' +import React, { Component, FormEvent } from 'react' import { CButton, CInput } from '@coreui/react' // The cloud part is written in old fashioned Class-components // because even if the hipsters say it's slow and retarted, i think it's prettier. -export class CloudUserPass extends Component { - constructor(props) { +interface CloudUserPassProps { + username: string | undefined + working: boolean + onClearError?: () => void + onAuth: (username: string, password: string) => void +} +interface CloudUserPassState { + username: string + password: string +} + +export class CloudUserPass extends Component { + constructor(props: CloudUserPassProps) { super(props) this.state = { @@ -41,7 +52,7 @@ export class CloudUserPass extends Component { this.setState({ username: e.target.value })} + onChange={(e: FormEvent) => this.setState({ username: e.currentTarget.value })} style={{ width: 500, }} @@ -57,7 +68,7 @@ export class CloudUserPass extends Component { this.setState({ password: e.target.value })} + onChange={(e: FormEvent) => this.setState({ password: e.currentTarget.value })} style={{ width: 500, }} @@ -66,7 +77,7 @@ export class CloudUserPass extends Component { Log in diff --git a/webui/src/Cloud/index.jsx b/webui/src/Cloud/index.tsx similarity index 85% rename from webui/src/Cloud/index.jsx rename to webui/src/Cloud/index.tsx index 60ace0ae14..eba3b781f1 100644 --- a/webui/src/Cloud/index.jsx +++ b/webui/src/Cloud/index.tsx @@ -5,29 +5,44 @@ import { CInput, CButton, CCallout, CCard, CCardBody, CCardHeader, CListGroup } import { CloudRegionPanel } from './RegionPanel' import { CloudUserPass } from './UserPass' import CSwitch from '../CSwitch' +import type { Socket } from 'socket.io-client' // The cloud part is written in old fashioned Class-components because I am most // familiar with it -export class Cloud extends Component { - /** - * @type {CloudControllerState} - */ +interface CloudControllerProps { + socket: Socket +} + +interface CloudControllerState { + uuid: string // the machine UUID + authenticating: boolean // is the cloud authenticating + authenticated: boolean // is the cloud authenticated + authenticatedAs: string | undefined // the cloud username + ping: boolean // is someone watching ping info? + regions: string[] // the cloud regions + error: null | string // the error message + cloudActive: boolean // is the cloud active + canActivate: boolean // can the cloud be activated +} + +export class Cloud extends Component { state = { - enabled: false, - error: null, - authenticated: false, uuid: '', authenticating: false, + authenticated: false, + authenticatedAs: undefined, + ping: false, + regions: [], + enabled: false, + error: null, cloudActive: false, canActivate: false, } - constructor(props) { + constructor(props: CloudControllerProps) { super(props) - this.regions = {} - this.cloudStateDidUpdate = this.cloudStateDidUpdate.bind(this) this.cloudSetState = this.cloudSetState.bind(this) } @@ -45,32 +60,30 @@ export class Cloud extends Component { this.props.socket.off('cloud_state', this.cloudStateDidUpdate) } - cloudStateDidUpdate(newState) { + private cloudStateDidUpdate(newState: Partial) { console.log('cloud state did update to:', { ...this.state, ...newState }) - this.setState({ ...newState }) + this.setState({ ...this.state, ...newState }) } /** * Set a new state for the cloud controller - * - * @param {Partial} newState */ - cloudSetState(newState) { + private cloudSetState(newState: Partial) { this.props.socket.emit('cloud_state_set', newState) } - cloudLogin(user, pass) { + private cloudLogin(user: string, pass: string) { this.props.socket.emit('cloud_login', user, pass) } /** * Regenerate the UUID for the cloud controller */ - cloudRegenerateUUID() { + private cloudRegenerateUUID() { this.props.socket.emit('cloud_regenerate_uuid') } - shouldComponentUpdate(_nextProps, nextState) { + shouldComponentUpdate(_nextProps: CloudControllerProps, nextState: CloudControllerState) { const a = JSON.stringify(nextState) const b = JSON.stringify(this.state) if (a !== b) { @@ -113,7 +126,7 @@ export class Cloud extends Component {
{ this.cloudLogin(user, pass) }} @@ -258,19 +271,3 @@ export class Cloud extends Component { ) } } - -/** - * @typedef {Object} CloudControllerState - * - * @property {string} uuid - the machine UUID - * @property {boolean} authenticating - is the cloud authenticating - * @property {boolean} authenticated - is the cloud authenticated - * @property {string} authenticatedAs - the cloud username - * @property {boolean} ping - is someone watching ping info? - * @property {string[]} regions - the cloud regions - * @property {string} tryUsername - the username to try - * @property {string} tryPassword - the password to try - * @property {null|string} error - the error message - * @property {boolean} cloudActive - is the cloud active - * @property {boolean} canActivate - can the cloud be activated - */ diff --git a/webui/src/CloudPage.jsx b/webui/src/CloudPage.tsx similarity index 100% rename from webui/src/CloudPage.jsx rename to webui/src/CloudPage.tsx diff --git a/webui/src/Components/AlignmentInputField.jsx b/webui/src/Components/AlignmentInputField.tsx similarity index 59% rename from webui/src/Components/AlignmentInputField.jsx rename to webui/src/Components/AlignmentInputField.tsx index 4b552b0cf8..1b1c199e9f 100644 --- a/webui/src/Components/AlignmentInputField.jsx +++ b/webui/src/Components/AlignmentInputField.tsx @@ -1,7 +1,8 @@ import React from 'react' import classnames from 'classnames' +import type { CompanionAlignment } from '@companion-module/base' -const ALIGMENT_OPTIONS = [ +const ALIGMENT_OPTIONS: CompanionAlignment[] = [ 'left:top', 'center:top', 'right:top', @@ -13,7 +14,12 @@ const ALIGMENT_OPTIONS = [ 'right:bottom', ] -export function AlignmentInputField({ value, setValue }) { +interface AlignmentInputFieldProps { + value: CompanionAlignment + setValue: (value: CompanionAlignment) => void +} + +export function AlignmentInputField({ value, setValue }: AlignmentInputFieldProps) { return (
{ALIGMENT_OPTIONS.map((align) => { diff --git a/webui/src/Components/BonjourDeviceInputField.jsx b/webui/src/Components/BonjourDeviceInputField.tsx similarity index 69% rename from webui/src/Components/BonjourDeviceInputField.jsx rename to webui/src/Components/BonjourDeviceInputField.tsx index 4b710e0fc6..bc4dc006f4 100644 --- a/webui/src/Components/BonjourDeviceInputField.jsx +++ b/webui/src/Components/BonjourDeviceInputField.tsx @@ -1,22 +1,27 @@ -import { createContext, useRef, useState } from 'react' -import { useContext } from 'react' -import { useMemo, useEffect } from 'react' +import React, { useContext, useRef, useState, useMemo, useEffect } from 'react' import { SocketContext, socketEmitPromise } from '../util' import { DropdownInputField } from './DropdownInputField' +import type { DropdownChoice, DropdownChoiceId } from '@companion-module/base' +import type { ClientBonjourService } from '@companion/shared/Model/Common' + +interface BonjourDeviceInputFieldProps { + value: string + setValue: (value: DropdownChoiceId) => void + connectionId: string + queryId: string +} -export const MenuPortalContext = createContext(null) - -export function BonjourDeviceInputField({ value, setValue, connectionId, queryId }) { +export function BonjourDeviceInputField({ value, setValue, connectionId, queryId }: BonjourDeviceInputFieldProps) { const socket = useContext(SocketContext) - const [_subId, setSubId] = useState(null) + const [_subId, setSubId] = useState(null) const subIdRef = useRef(null) - const [services, setServices] = useState({}) + const [services, setServices] = useState>({}) // Listen for data useEffect(() => { - const onUp = (svc) => { + const onUp = (svc: ClientBonjourService) => { if (svc.subId !== subIdRef.current) return // console.log('up', svc) @@ -28,7 +33,7 @@ export function BonjourDeviceInputField({ value, setValue, connectionId, queryId } }) } - const onDown = (svc) => { + const onDown = (svc: ClientBonjourService) => { if (svc.subId !== subIdRef.current) return // console.log('down', svc) @@ -52,7 +57,7 @@ export function BonjourDeviceInputField({ value, setValue, connectionId, queryId // Start/Stop the subscription useEffect(() => { let killed = false - let mySubId = null + let mySubId: string | null = null socketEmitPromise(socket, 'bonjour:subscribe', [connectionId, queryId]) .then((newSubId) => { // Make sure it hasnt been terminated @@ -83,11 +88,12 @@ export function BonjourDeviceInputField({ value, setValue, connectionId, queryId }, [socket, connectionId, queryId]) const choicesRaw = useMemo(() => { - const choices = [] + const choices: DropdownChoice[] = [] - choices.push({ id: null, label: 'Manual' }) + choices.push({ id: null as any, label: 'Manual' }) for (const svc of Object.values(services)) { + if (!svc) continue for (const rawAddress of svc.addresses || []) { const address = `${rawAddress}:${svc.port}` choices.push({ @@ -113,5 +119,5 @@ export function BonjourDeviceInputField({ value, setValue, connectionId, queryId return choices }, [choicesRaw, value]) - return + return value={value} setValue={setValue} choices={choices} multiple={false} /> } diff --git a/webui/src/Components/ButtonPreview.jsx b/webui/src/Components/ButtonPreview.jsx deleted file mode 100644 index 2ad4c68016..0000000000 --- a/webui/src/Components/ButtonPreview.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react' -import classnames from 'classnames' - -// Single pixel of red -export const RedImage = - 'data:image/bmp;base64,Qk2OAAAAAAAAAIoAAAB8AAAAAQAAAP////8BACAAAwAAAAQAAAAnAAAAJwAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/0JHUnMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAA==' - -export const ButtonPreview = React.memo(function (props) { - const classes = { - 'button-control': true, - fixed: !!props.fixedSize, - drophere: props.canDrop, - drophover: props.dropHover, - draggable: !!props.dragRef, - selected: props.selected, - clickable: !!props.onClick, - right: !!props.right, - } - - return ( -
props?.onClick?.(props.location, true)} - onMouseUp={() => props?.onClick?.(props.location, false)} - onTouchStart={(e) => { - e.preventDefault() - props?.onClick?.(props.location, true) - }} - onTouchEnd={(e) => { - e.preventDefault() - props?.onClick?.(props.location, false) - }} - onTouchCancel={(e) => { - e.preventDefault() - e.stopPropagation() - - props?.onClick?.(props.location, false) - }} - onContextMenu={(e) => { - e.preventDefault() - e.stopPropagation() - return false - }} - > -
- {!props.preview && props.placeholder &&
{props.placeholder}
} -
-
- ) -}) diff --git a/webui/src/Components/ButtonPreview.tsx b/webui/src/Components/ButtonPreview.tsx new file mode 100644 index 0000000000..14a842ea64 --- /dev/null +++ b/webui/src/Components/ButtonPreview.tsx @@ -0,0 +1,133 @@ +import React from 'react' +import classnames from 'classnames' +import type { ControlLocation } from '@companion/shared/Model/Common' + +// Single pixel of red +export const RedImage: string = + 'data:image/bmp;base64,Qk2OAAAAAAAAAIoAAAB8AAAAAQAAAP////8BACAAAwAAAAQAAAAnAAAAJwAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/0JHUnMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAA==' + +export interface ButtonPreviewProps extends Omit { + onClick?: (location: ControlLocation, pressed: boolean) => void + location: ControlLocation +} + +export const ButtonPreview = React.memo(function ButtonPreview(props: ButtonPreviewProps) { + const classes = { + 'button-control': true, + fixed: !!props.fixedSize, + drophere: props.canDrop, + drophover: props.dropHover, + draggable: !!props.dragRef, + selected: props.selected, + clickable: !!props.onClick, + right: !!props.right, + } + + return ( +
props?.onClick?.(props.location, true)} + onMouseUp={() => props?.onClick?.(props.location, false)} + onTouchStart={(e) => { + e.preventDefault() + props?.onClick?.(props.location, true) + }} + onTouchEnd={(e) => { + e.preventDefault() + props?.onClick?.(props.location, false) + }} + onTouchCancel={(e) => { + e.preventDefault() + e.stopPropagation() + + props?.onClick?.(props.location, false) + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + return false + }} + > +
+ {!props.preview && props.placeholder &&
{props.placeholder}
} +
+
+ ) +}) + +export interface ButtonPreviewBaseProps { + fixedSize?: boolean + canDrop?: boolean + dropHover?: boolean + dragRef?: React.RefCallback + selected?: boolean + onClick?: (pressed: boolean) => void + right?: boolean + dropRef?: React.RefCallback + style?: React.CSSProperties + preview: string | undefined | null | false + placeholder?: string + title?: string +} + +export const ButtonPreviewBase = React.memo(function ButtonPreview(props: ButtonPreviewBaseProps) { + const classes = { + 'button-control': true, + fixed: !!props.fixedSize, + drophere: props.canDrop, + drophover: props.dropHover, + draggable: !!props.dragRef, + selected: props.selected, + clickable: !!props.onClick, + right: !!props.right, + } + + return ( +
props.onClick?.(true)} + onMouseUp={() => props.onClick?.(false)} + onTouchStart={(e) => { + e.preventDefault() + props?.onClick?.(true) + }} + onTouchEnd={(e) => { + e.preventDefault() + props?.onClick?.(false) + }} + onTouchCancel={(e) => { + e.preventDefault() + e.stopPropagation() + + props?.onClick?.(false) + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + return false + }} + > +
+ {!props.preview && props.placeholder &&
{props.placeholder}
} +
+
+ ) +}) diff --git a/webui/src/Components/Card.jsx b/webui/src/Components/Card.jsx deleted file mode 100644 index c31e24edd9..0000000000 --- a/webui/src/Components/Card.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -export class Card extends React.Component { - render() { - return ( -
- {this.props.children} -
- ) - } -} diff --git a/webui/src/Components/CheckboxInputField.jsx b/webui/src/Components/CheckboxInputField.tsx similarity index 65% rename from webui/src/Components/CheckboxInputField.jsx rename to webui/src/Components/CheckboxInputField.tsx index 75813c02b9..e9b5bbcf33 100644 --- a/webui/src/Components/CheckboxInputField.jsx +++ b/webui/src/Components/CheckboxInputField.tsx @@ -1,7 +1,15 @@ -import { useEffect, useCallback } from 'react' +import React, { useEffect, useCallback } from 'react' import { CInputCheckbox } from '@coreui/react' -export function CheckboxInputField({ tooltip, value, setValue, setValid, disabled }) { +interface CheckboxInputFieldProps { + tooltip?: string + value: boolean + setValue: (value: boolean) => void + setValid?: (valid: boolean) => void + disabled?: boolean +} + +export function CheckboxInputField({ tooltip, value, setValue, setValid, disabled }: CheckboxInputFieldProps) { // If the value is undefined, populate with the default. Also inform the parent about the validity useEffect(() => { setValid?.(true) @@ -20,7 +28,7 @@ export function CheckboxInputField({ tooltip, value, setValue, setValid, disable type="checkbox" disabled={disabled} checked={!!value} - value={true} + value={true as any} title={tooltip} onChange={onChange} /> diff --git a/webui/src/Components/ColorInputField.jsx b/webui/src/Components/ColorInputField.tsx similarity index 77% rename from webui/src/Components/ColorInputField.jsx rename to webui/src/Components/ColorInputField.tsx index 4f9d450fdf..b1d74ecb4f 100644 --- a/webui/src/Components/ColorInputField.jsx +++ b/webui/src/Components/ColorInputField.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect, useCallback, useContext } from 'react' -import { SketchPicker } from '@hello-pangea/color-picker' +import React, { useState, useEffect, useCallback, useContext } from 'react' +import { ColorResult, SketchPicker } from '@hello-pangea/color-picker' import { createPortal } from 'react-dom' import { useOnClickOutsideExt } from '../util' import { usePopper } from 'react-popper' import { MenuPortalContext } from './DropdownInputField' import { colord } from 'colord' +import { CompanionColorPresetValue } from '@companion-module/base' -function splitColor(color) { +function splitColor(color: number | string) { if (typeof color === 'number') { if (color > 0xffffff) { return { @@ -41,22 +42,45 @@ function splitColor(color) { } } -const toReturnType = (value, returnType) => { +const toReturnType = ( + value: ColorResult, + returnType: 'string' | 'number' +): AsType => { if (returnType === 'string') { - return `rgba(${value.rgb.r}, ${value.rgb.g}, ${value.rgb.b}, ${value.rgb.a})` + return `rgba(${value.rgb.r}, ${value.rgb.g}, ${value.rgb.b}, ${value.rgb.a})` as any // TODO - typings } else { let colorNumber = parseInt(value.hex.substr(1), 16) if (value.rgb.a && value.rgb.a !== 1) { colorNumber += 0x1000000 * Math.round(255 * (1 - value.rgb.a)) // add possible transparency to number } - return colorNumber + return colorNumber as any // TODO - typings } } -export function ColorInputField({ value, setValue, setValid, disabled, enableAlpha, returnType, presetColors }) { +type AsType = T extends 'string' ? string : number + +interface ColorInputFieldProps { + value: AsType + setValue: (value: AsType) => void + setValid?: (valid: boolean) => void + disabled?: boolean + enableAlpha?: boolean + returnType: 'string' | 'number' + presetColors?: CompanionColorPresetValue[] +} + +export function ColorInputField({ + value, + setValue, + setValid, + // disabled, + enableAlpha, + returnType, + presetColors, +}: ColorInputFieldProps) { const menuPortal = useContext(MenuPortalContext) - const [currentColor, setCurrentColor] = useState(null) + const [currentColor, setCurrentColor] = useState | null>(null) const [displayPicker, setDisplayPicker] = useState(false) // If the value is undefined, populate with the default. Also inform the parent about the validity @@ -64,7 +88,7 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp setValid?.(true) }, [setValid]) - const handleClick = useCallback((e) => setDisplayPicker((d) => !d), []) + const handleClick = useCallback(() => setDisplayPicker((d) => !d), []) const setHide = useCallback((e) => { if (e) { e.preventDefault() @@ -75,8 +99,8 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp }, []) const onChange = useCallback( - (c) => { - const newValue = toReturnType(c, returnType) + (c: ColorResult) => { + const newValue = toReturnType(c, returnType) console.log('change', newValue) setValue(newValue) setValid?.(true) @@ -86,8 +110,8 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp ) const onChangeComplete = useCallback( - (c) => { - const newValue = toReturnType(c, returnType) + (c: ColorResult) => { + const newValue = toReturnType(c, returnType) console.log('complete', newValue) setValue(newValue) setValid?.(true) @@ -117,8 +141,8 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp }, } - const [referenceElement, setReferenceElement] = useState(null) - const [popperElement, setPopperElement] = useState(null) + const [referenceElement, setReferenceElement] = useState(null) + const [popperElement, setPopperElement] = useState(null) const { styles: popperStyles, attributes } = usePopper(referenceElement, popperElement) useOnClickOutsideExt([{ current: referenceElement }, { current: popperElement }], setHide) @@ -131,12 +155,12 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp createPortal(
, menuPortal || document.body @@ -145,7 +169,7 @@ export function ColorInputField({ value, setValue, setValid, disabled, enableAlp ) } -const PICKER_COLORS = [ +const PICKER_COLORS: CompanionColorPresetValue[] = [ //Grey { color: '#000000', title: 'Black' }, { color: '#242424', title: '14% White' }, diff --git a/webui/src/Components/ConfirmExportModal.jsx b/webui/src/Components/ConfirmExportModal.jsx deleted file mode 100644 index 13453b7d0f..0000000000 --- a/webui/src/Components/ConfirmExportModal.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { CButton, CLabel, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' -import { ExportFormatDefault, SelectExportFormat } from '../ImportExport/ExportFormat' -import { MenuPortalContext } from './DropdownInputField' -import { windowLinkOpen } from '../Helpers/Window' - -export const ConfirmExportModal = forwardRef(function ConfirmExportModal(props, ref) { - const [data, setData] = useState(null) - const [show, setShow] = useState(false) - const [format, setFormat] = useState(ExportFormatDefault) - - const buttonRef = useRef() - - const buttonFocus = () => { - if (buttonRef.current) { - buttonRef.current.focus() - } - } - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setData(null), []) - const doAction = useCallback(() => { - setData(null) - setShow(false) - - const url = new URL(data, window.location.origin) - url.searchParams.set('format', format) - - windowLinkOpen({ href: url.toString() }) - }, [data, format]) - - useImperativeHandle( - ref, - () => ({ - show(url) { - setData(url) - setShow(true) - - // Focus the button asap. It also gets focused once the open is complete - setTimeout(buttonFocus, 50) - }, - }), - [] - ) - - const [modalRef, setModalRef] = useState(null) - - return ( - - - -
{props.title}
-
- -
-
-
- File format -   - -
-
-
-
- - - Cancel - - - Export - - -
-
- ) -}) diff --git a/webui/src/Components/ConfirmExportModal.tsx b/webui/src/Components/ConfirmExportModal.tsx new file mode 100644 index 0000000000..935a778993 --- /dev/null +++ b/webui/src/Components/ConfirmExportModal.tsx @@ -0,0 +1,88 @@ +import { CButton, CLabel, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' +import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' +import { ExportFormatDefault, SelectExportFormat } from '../ImportExport/ExportFormat' +import { MenuPortalContext } from './DropdownInputField' +import { windowLinkOpen } from '../Helpers/Window' + +export interface ConfirmExportModalRef { + show(url: string): void +} + +interface ConfirmExportModalProps { + title?: string +} + +export const ConfirmExportModal = forwardRef( + function ConfirmExportModal(props, ref) { + const [data, setData] = useState(null) + const [show, setShow] = useState(false) + const [format, setFormat] = useState(ExportFormatDefault) + + const buttonRef = useRef(null) + + const buttonFocus = () => { + if (buttonRef.current) { + buttonRef.current.focus() + } + } + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => setData(null), []) + const doAction = useCallback(() => { + setData(null) + setShow(false) + + if (data) { + const url = new URL(data, window.location.origin) + url.searchParams.set('format', format) + + windowLinkOpen({ href: url.toString() }) + } + }, [data, format]) + + useImperativeHandle( + ref, + () => ({ + show(url) { + setData(url) + setShow(true) + + // Focus the button asap. It also gets focused once the open is complete + setTimeout(buttonFocus, 50) + }, + }), + [] + ) + + const [modalRef, setModalRef] = useState(null) + + return ( + + + +
{props.title}
+
+ +
+
+
+ File format +   + +
+
+
+
+ + + Cancel + + + Export + + +
+
+ ) + } +) diff --git a/webui/src/Components/DropdownInputField.jsx b/webui/src/Components/DropdownInputField.tsx similarity index 73% rename from webui/src/Components/DropdownInputField.jsx rename to webui/src/Components/DropdownInputField.tsx index d5180b51b2..688d9b6045 100644 --- a/webui/src/Components/DropdownInputField.jsx +++ b/webui/src/Components/DropdownInputField.tsx @@ -1,13 +1,34 @@ +import { DropdownChoice, DropdownChoiceId } from '@companion-module/base' import classNames from 'classnames' -import { createContext } from 'react' -import { useContext } from 'react' -import { useMemo, useEffect, useCallback, memo } from 'react' +import React, { createContext, useContext, useMemo, useEffect, useCallback, memo } from 'react' import Select from 'react-select' -import CreatableSelect from 'react-select/creatable' - -export const MenuPortalContext = createContext(null) - -export const DropdownInputField = memo(function DropdownInputField({ +import CreatableSelect, { CreatableProps } from 'react-select/creatable' + +export const MenuPortalContext = createContext(null) + +type AsType = Multi extends true ? DropdownChoiceId[] : DropdownChoiceId + +interface DropdownInputFieldProps { + choices: DropdownChoice[] | Record + allowCustom?: boolean + minSelection?: number + minChoicesForSearch?: number + maxSelection?: number + tooltip?: string + regex?: string + multiple: Multi + value: AsType + setValue: (value: AsType) => void + setValid?: (valid: boolean) => void + disabled?: boolean +} + +interface DropdownChoiceInt { + value: any + label: DropdownChoiceId +} + +export const DropdownInputField = memo(function DropdownInputField({ choices, allowCustom, minSelection, @@ -20,11 +41,11 @@ export const DropdownInputField = memo(function DropdownInputField({ setValue, setValid, disabled, -}) { +}: DropdownInputFieldProps) { const menuPortal = useContext(MenuPortalContext) const options = useMemo(() => { - let options = [] + let options: DropdownChoice[] = [] if (options) { if (Array.isArray(choices)) { options = choices @@ -33,16 +54,16 @@ export const DropdownInputField = memo(function DropdownInputField({ } } - return options.map((choice) => ({ value: choice.id, label: choice.label })) + return options.map((choice): DropdownChoiceInt => ({ value: choice.id, label: choice.label })) }, [choices]) const isMultiple = !!multiple - if (isMultiple && value === undefined) value = [] + if (isMultiple && value === undefined) value = [] as any const currentValue = useMemo(() => { const selectedValue = Array.isArray(value) ? value : [value] - let res = [] + let res: DropdownChoiceInt[] = [] for (const val of selectedValue) { // eslint-disable-next-line eqeqeq const entry = options.find((o) => o.value == val) // Intentionally loose for compatibility @@ -104,17 +125,18 @@ export const DropdownInputField = memo(function DropdownInputField({ }, [value, setValid, isValueValid]) const onChange = useCallback( - (e) => { + (e: DropdownChoiceInt | DropdownChoiceInt[]) => { const isMultiple = !!multiple - const newValue = isMultiple ? e?.map((v) => v.value) ?? [] : e?.value + const newValue = Array.isArray(e) ? e?.map((v) => v.value) ?? [] : e?.value const isValid = isValueValid(newValue) if (isMultiple) { + const valueArr = value as DropdownChoiceId[] | undefined if ( typeof minSelection === 'number' && newValue.length < minSelection && - newValue.length <= (value || []).length + newValue.length <= (valueArr || []).length ) { // Block change if too few are selected return @@ -123,7 +145,7 @@ export const DropdownInputField = memo(function DropdownInputField({ if ( typeof maxSelection === 'number' && newValue.length > maxSelection && - newValue.length >= (value || []).length + newValue.length >= (valueArr || []).length ) { // Block change if too many are selected return @@ -138,7 +160,7 @@ export const DropdownInputField = memo(function DropdownInputField({ const minChoicesForSearch2 = typeof minChoicesForSearch === 'number' ? minChoicesForSearch : 10 - const selectProps = { + const selectProps: Partial> = { isDisabled: disabled, classNamePrefix: 'select-control', menuPortalTarget: menuPortal || document.body, @@ -193,4 +215,4 @@ export const DropdownInputField = memo(function DropdownInputField({ )}
) -}) +}) as (props: DropdownInputFieldProps) => JSX.Element diff --git a/webui/src/Components/GenericConfirmModal.jsx b/webui/src/Components/GenericConfirmModal.jsx deleted file mode 100644 index 9d41d6a6f1..0000000000 --- a/webui/src/Components/GenericConfirmModal.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import { CButton, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' - -export const GenericConfirmModal = forwardRef(function GenericConfirmModal(props, ref) { - const [data, setData] = useState(null) - const [show, setShow] = useState(false) - - const buttonRef = useRef() - - const buttonFocus = () => { - if (buttonRef.current) { - buttonRef.current.focus() - } - } - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setData(null), []) - const doAction = useCallback(() => { - setData(null) - setShow(false) - - // completion callback - const cb = data?.[3] - cb() - }, [data]) - - useImperativeHandle( - ref, - () => ({ - show(title, message, buttonLabel, completeCallback) { - setData([title, message, buttonLabel, completeCallback]) - setShow(true) - - // Focus the button asap. It also gets focused once the open is complete - setTimeout(buttonFocus, 50) - }, - }), - [] - ) - - let content = props.content ?? '' - if (data?.[1]) { - if (Array.isArray(data?.[1])) { - content = data?.[1].map((line) =>

{line}

) - } else { - content =

{data?.[1]}

- } - } - - return ( - - -
{data?.[0]}
-
- {content} - - - Cancel - - - {data?.[2]} - - -
- ) -}) diff --git a/webui/src/Components/GenericConfirmModal.tsx b/webui/src/Components/GenericConfirmModal.tsx new file mode 100644 index 0000000000..66f15d23bf --- /dev/null +++ b/webui/src/Components/GenericConfirmModal.tsx @@ -0,0 +1,83 @@ +import React, { CButton, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' + +export interface GenericConfirmModalRef { + show(title: string, message: string | string[] | null, buttonLabel: string, completeCallback: () => void): void +} + +interface GenericConfirmModalProps { + content?: string | JSX.Element | JSX.Element[] +} + +interface GenericConfirmModalData { + title: string + message: string | string[] | null + buttonLabel: string + completeCallback: () => void +} + +export const GenericConfirmModal = forwardRef( + function GenericConfirmModal(props, ref) { + const [data, setData] = useState(null) + const [show, setShow] = useState(false) + + const buttonRef = useRef(null) + + const buttonFocus = () => { + if (buttonRef.current) { + buttonRef.current.focus() + } + } + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => setData(null), []) + const doAction = useCallback(() => { + setData(null) + setShow(false) + + // completion callback + const cb = data?.completeCallback + if (cb) cb() + }, [data]) + + useImperativeHandle( + ref, + () => ({ + show(title, message, buttonLabel, completeCallback) { + setData({ title, message, buttonLabel, completeCallback }) + setShow(true) + + // Focus the button asap. It also gets focused once the open is complete + setTimeout(buttonFocus, 50) + }, + }), + [] + ) + + let content: JSX.Element | JSX.Element[] | string = props.content ?? '' + if (data?.message) { + if (Array.isArray(data.message)) { + content = data.message.map((line) =>

{line}

) + } else { + content =

{data.message}

+ } + } + + return ( + + +
{data?.title}
+
+ {content} + + + Cancel + + + {data?.buttonLabel} + + +
+ ) + } +) diff --git a/webui/src/Components/Notifications.jsx b/webui/src/Components/Notifications.jsx deleted file mode 100644 index 842dd03dde..0000000000 --- a/webui/src/Components/Notifications.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { CToast, CToastBody, CToaster, CToastHeader } from '@coreui/react' -import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' -import { nanoid } from 'nanoid' - -export const NotificationsManager = forwardRef(function NotificationsManager(_props, ref) { - const [toasts, setToasts] = useState([]) - - const doPruneToastIdInner = useCallback((id) => { - setToasts((oldToasts) => oldToasts.filter((t) => t.id !== id)) - }, []) - const doPruneToastId = useCallback( - (id, duration) => { - setTimeout(() => { - // now prune them - doPruneToastIdInner(id) - }, 3000 + duration) - }, - [doPruneToastIdInner] - ) - const doDisposeToastId = useCallback( - (id) => { - // hide them - setToasts((oldToasts) => oldToasts.map((t) => (t.id === id ? { ...t, autohide: 1 } : t))) - - doPruneToastIdInner(id) - }, - [doPruneToastIdInner] - ) - - // Expose reload to the parent - useImperativeHandle( - ref, - () => ({ - show(title, message, duration, stickyId) { - const id = stickyId ?? nanoid() - - const autohide = duration === null ? undefined : duration ?? 10000 - if (typeof autohide === 'number') { - doPruneToastId(id, autohide) - } - - setToasts((oldToasts) => [ - ...oldToasts.filter((t) => t.id !== id), - { - id: id, - message: message ?? title, - title: title, - show: true, - autohide: autohide, - }, - ]) - - return id - }, - close(id) { - doDisposeToastId(id) - }, - }), - [doDisposeToastId, doPruneToastId] - ) - - return ( - <> - - {toasts.map((toast) => { - return ( - - {toast.title} - {toast.message} - - ) - })} - - - ) -}) diff --git a/webui/src/Components/Notifications.tsx b/webui/src/Components/Notifications.tsx new file mode 100644 index 0000000000..7083239f00 --- /dev/null +++ b/webui/src/Components/Notifications.tsx @@ -0,0 +1,98 @@ +import { CToast, CToastBody, CToaster, CToastHeader } from '@coreui/react' +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import { nanoid } from 'nanoid' + +export interface NotificationsManagerRef { + show(title: string, message: string, duration?: number, stickyId?: string): string + close(messageId: string): void +} + +interface NotificationsManagerProps { + // Nothing +} + +interface CurrentToast { + id: string + message: string + title: string + show: boolean + autohide: number | undefined + + fade?: never + closeButton?: never +} + +export const NotificationsManager = forwardRef( + function NotificationsManager(_props, ref) { + const [toasts, setToasts] = useState([]) + + const doPruneToastIdInner = useCallback((id: string) => { + setToasts((oldToasts) => oldToasts.filter((t) => t.id !== id)) + }, []) + const doPruneToastId = useCallback( + (id: string, duration: number) => { + setTimeout(() => { + // now prune them + doPruneToastIdInner(id) + }, 3000 + duration) + }, + [doPruneToastIdInner] + ) + const doDisposeToastId = useCallback( + (id: string) => { + // hide them + setToasts((oldToasts) => oldToasts.map((t) => (t.id === id ? { ...t, autohide: 1 } : t))) + + doPruneToastIdInner(id) + }, + [doPruneToastIdInner] + ) + + // Expose reload to the parent + useImperativeHandle( + ref, + () => ({ + show(title, message, duration, stickyId) { + const id = stickyId ?? nanoid() + + const autohide = duration === null ? undefined : duration ?? 10000 + if (typeof autohide === 'number') { + doPruneToastId(id, autohide) + } + + setToasts((oldToasts) => [ + ...oldToasts.filter((t) => t.id !== id), + { + id: id, + message: message ?? title, + title: title, + show: true, + autohide: autohide, + }, + ]) + + return id + }, + close(id) { + doDisposeToastId(id) + }, + }), + [doDisposeToastId, doPruneToastId] + ) + + return ( + <> + + {toasts.map((toast) => { + return ( + + {toast.title} + {toast.message} + + ) + })} + + + ) + } +) diff --git a/webui/src/Components/NumberInputField.jsx b/webui/src/Components/NumberInputField.tsx similarity index 71% rename from webui/src/Components/NumberInputField.jsx rename to webui/src/Components/NumberInputField.tsx index 7fbd8c7fab..e69a5f97a3 100644 --- a/webui/src/Components/NumberInputField.jsx +++ b/webui/src/Components/NumberInputField.tsx @@ -1,28 +1,53 @@ import React, { useEffect, useCallback, useState } from 'react' import { CCol, CInput, CRow } from '@coreui/react' -export function NumberInputField({ required, min, max, step, tooltip, range, value, setValue, setValid, disabled }) { - const [tmpValue, setTmpValue] = useState(null) +interface NumberInputFieldProps { + required?: boolean + min?: number + max?: number + step?: number + tooltip?: string + range?: boolean + value: number + setValue: (value: number) => void + setValid?: (valid: boolean) => void + disabled?: boolean +} + +export function NumberInputField({ + required, + min, + max, + step, + tooltip, + range, + value, + setValue, + setValid, + disabled, +}: NumberInputFieldProps) { + const [tmpValue, setTmpValue] = useState(null) // Check if the value is valid const isValueValid = useCallback( - (val) => { + (val: string | number) => { if (val === '') { // If required, it must not be empty if (required) { return false } } else { + const valNum = Number(val) // If has a value, it must be a number - if (isNaN(val)) { + if (isNaN(valNum)) { return false } // Verify the value range - if (min !== undefined && val < min) { + if (min !== undefined && valNum < min) { return false } - if (max !== undefined && val > max) { + if (max !== undefined && valNum > max) { return false } } @@ -38,11 +63,11 @@ export function NumberInputField({ required, min, max, step, tooltip, range, val }, [isValueValid, value, setValid]) const onChange = useCallback( - (e) => { + (e: React.FormEvent) => { const parsedValue = parseFloat(e.currentTarget.value) const processedValue = isNaN(parsedValue) ? e.currentTarget.value : parsedValue setTmpValue(processedValue) - setValue(processedValue) + setValue(Number(processedValue)) setValid?.(isValueValid(processedValue)) }, [setValue, setValid, isValueValid] diff --git a/webui/src/Components/PNGInputField.jsx b/webui/src/Components/PNGInputField.tsx similarity index 76% rename from webui/src/Components/PNGInputField.jsx rename to webui/src/Components/PNGInputField.tsx index 35fca57d15..067f7d1894 100644 --- a/webui/src/Components/PNGInputField.jsx +++ b/webui/src/Components/PNGInputField.tsx @@ -3,12 +3,24 @@ import { CButton, CInputFile } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faFolderOpen } from '@fortawesome/free-solid-svg-icons' -export function PNGInputField({ min, max, onSelect, onError }) { - const inputRef = useRef() +interface MinMaxDimension { + width: number + height: number +} + +interface PNGInputFieldProps { + min: MinMaxDimension + max: MinMaxDimension + onSelect: (png64Str: string, name: string) => void + onError: (err: string | null) => void +} + +export function PNGInputField({ min, max, onSelect, onError }: PNGInputFieldProps) { + const inputRef = useRef(null) const apiIsSupported = !!(window.File && window.FileReader && window.FileList && window.Blob) - const imageResize = (img, maxWidth, maxHeight) => { + const imageResize = (img: HTMLImageElement, maxWidth: number, maxHeight: number) => { const canvas = document.createElement('canvas') let width = img.width let height = img.height @@ -29,13 +41,16 @@ export function PNGInputField({ min, max, onSelect, onError }) { canvas.height = height const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Not supported!') ctx.drawImage(img, 0, 0, width, height) return canvas.toDataURL() } const onClick = useCallback(() => { onError(null) - inputRef.current.click() + if (inputRef.current) { + inputRef.current.click() + } }, [onError]) const onChange = useCallback( (e) => { @@ -56,6 +71,8 @@ export function PNGInputField({ min, max, onSelect, onError }) { var img = new Image() img.onload = () => { + if (!fr.result) return + // image is loaded; sizes are available if (max && (img.height > max.height || img.width > max.width)) { onError(null) @@ -66,11 +83,13 @@ export function PNGInputField({ min, max, onSelect, onError }) { onError(`Image dimensions must be at most ${max.width}x${max.height}`) } else { onError(null) - onSelect(fr.result, newFiles[0].name) + onSelect(fr.result.toString(), newFiles[0].name) } } - img.src = fr.result // is the data URL because called with readAsDataURL + if (fr.result) { + img.src = fr.result.toString() // is the data URL because called with readAsDataURL + } } fr.readAsDataURL(newFiles[0]) } else { diff --git a/webui/src/Components/TextInputField.jsx b/webui/src/Components/TextInputField.tsx similarity index 79% rename from webui/src/Components/TextInputField.jsx rename to webui/src/Components/TextInputField.tsx index 2104b47427..13cb70b5ce 100644 --- a/webui/src/Components/TextInputField.jsx +++ b/webui/src/Components/TextInputField.tsx @@ -1,8 +1,28 @@ import Tribute from 'tributejs' -import { useEffect, useMemo, useState, useCallback, useContext } from 'react' +import React, { useEffect, useMemo, useState, useCallback, useContext, ChangeEvent } from 'react' import { CInput } from '@coreui/react' import { VariableDefinitionsContext } from '../util' +interface TextInputFieldProps { + regex?: string + required?: boolean + tooltip?: string + placeholder?: string + value: string + style?: React.CSSProperties + setValue: (value: string) => void + setValid?: (valid: boolean) => void + disabled?: boolean + useVariables?: boolean + useInternalLocationVariables?: boolean +} + +interface TributeSuggestion { + key: string + value: string + label: string +} + export function TextInputField({ regex, required, @@ -15,14 +35,14 @@ export function TextInputField({ disabled, useVariables, useInternalLocationVariables, -}) { +}: TextInputFieldProps) { const variableDefinitionsContext = useContext(VariableDefinitionsContext) - const [tmpValue, setTmpValue] = useState(null) + const [tmpValue, setTmpValue] = useState(null) const tribute = useMemo(() => { // Create it once, then we attach and detach whenever the ref changes - return new Tribute({ + return new Tribute({ values: [], trigger: '$(', @@ -37,10 +57,11 @@ export function TextInputField({ useEffect(() => { // Update the suggestions list in tribute whenever anything changes - const suggestions = [] + const suggestions: TributeSuggestion[] = [] if (useVariables) { for (const [connectionLabel, variables] of Object.entries(variableDefinitionsContext)) { for (const [name, va] of Object.entries(variables || {})) { + if (!va) continue const variableId = `${connectionLabel}:${name}` suggestions.push({ key: variableId + ')', @@ -88,7 +109,7 @@ export function TextInputField({ // Check if the value is valid const isValueValid = useCallback( - (val) => { + (val: string) => { // We need a string here, but sometimes get a number... if (typeof val === 'number') { val = `${val}` @@ -117,7 +138,7 @@ export function TextInputField({ }, [isValueValid, value, setValid]) const doOnChange = useCallback( - (e) => { + (e: React.ChangeEvent) => { // const newValue = decode(e.currentTarget.value, { scope: 'strict' }) setTmpValue(e.currentTarget.value) setValue(e.currentTarget.value) @@ -126,19 +147,23 @@ export function TextInputField({ [setValue, setValid, isValueValid] ) - const [, setupTributePrevious] = useState([null, null]) + const [, setupTributePrevious] = useState< + [HTMLInputElement | null, ((e: React.ChangeEvent) => void) | null] + >([null, null]) const setupTribute = useCallback( - (ref) => { + (ref: HTMLInputElement) => { // we need to detach, so need to track the value manually setupTributePrevious(([oldRef, oldDoOnChange]) => { if (oldRef) { tribute.detach(oldRef) if (oldDoOnChange) { + // @ts-expect-error oldRef.removeEventListener('tribute-replaced', oldDoOnChange) } } if (ref) { tribute.attach(ref) + // @ts-expect-error ref.addEventListener('tribute-replaced', doOnChange) } return [ref, doOnChange] diff --git a/webui/src/Components/VariablesTable.jsx b/webui/src/Components/VariablesTable.tsx similarity index 65% rename from webui/src/Components/VariablesTable.jsx rename to webui/src/Components/VariablesTable.tsx index 3e27e37dbb..5526bbce7b 100644 --- a/webui/src/Components/VariablesTable.jsx +++ b/webui/src/Components/VariablesTable.tsx @@ -4,22 +4,34 @@ import { SocketContext, socketEmitPromise, NotifierContext, VariableDefinitionsC import { CopyToClipboard } from 'react-copy-to-clipboard' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCopy, faTimes } from '@fortawesome/free-solid-svg-icons' +import { CompanionVariableValues, type CompanionVariableValue } from '@companion-module/base' +import type { VariableDefinition } from '@companion/shared/Model/Variables' -export function VariablesTable({ label }) { +interface VariablesTableProps { + label: string +} + +interface VariableDefinitionExt extends VariableDefinition { + name: string +} + +export function VariablesTable({ label }: VariablesTableProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const variableDefinitionsContext = useContext(VariableDefinitionsContext) - const [variableValues, setVariableValues] = useState({}) + const [variableValues, setVariableValues] = useState({}) const [filter, setFilter] = useState('') const variableDefinitions = useMemo(() => { - const defs = [] + const defs: VariableDefinitionExt[] = [] for (const [name, variable] of Object.entries(variableDefinitionsContext[label] || {})) { - defs.push({ - ...variable, - name, - }) + if (variable) { + defs.push({ + ...variable, + name, + }) + } } defs.sort((a, b) => @@ -32,34 +44,34 @@ export function VariablesTable({ label }) { }, [variableDefinitionsContext, label]) useEffect(() => { - if (label) { - const doPoll = () => { - socketEmitPromise(socket, 'variables:instance-values', [label]) - .then((values) => { - setVariableValues(values || {}) - }) - .catch((e) => { - setVariableValues({}) - console.log('Failed to fetch variable values: ', e) - }) - } + if (!label) return + + const doPoll = () => { + socketEmitPromise(socket, 'variables:instance-values', [label]) + .then((values) => { + setVariableValues(values || {}) + }) + .catch((e) => { + setVariableValues({}) + console.log('Failed to fetch variable values: ', e) + }) + } - doPoll() - const interval = setInterval(doPoll, 1000) + doPoll() + const interval = setInterval(doPoll, 1000) - return () => { - setVariableValues({}) - clearInterval(interval) - } + return () => { + setVariableValues({}) + clearInterval(interval) } }, [socket, label]) const onCopied = useCallback(() => { - notifier.current.show(`Copied`, 'Copied to clipboard', 5000) + notifier.current?.show(`Copied`, 'Copied to clipboard', 5000) }, [notifier]) const [candidates, errorMsg] = useMemo(() => { - let candidates = [] + let candidates: VariableDefinitionExt[] = [] try { if (!filter) { candidates = variableDefinitions @@ -142,21 +154,30 @@ export function VariablesTable({ label }) { ) } -const VariablesTableRow = memo(function VariablesTableRow({ variable, value, label, onCopied }) { - if (typeof value !== 'string') { - value += '' - } +interface VariablesTableRowProps { + variable: VariableDefinitionExt + label: string + value: CompanionVariableValue | undefined + onCopied: () => void +} + +const VariablesTableRow = memo(function VariablesTableRow({ + variable, + value: valueRaw, + label, + onCopied, +}: VariablesTableRowProps) { + const value = typeof valueRaw !== 'string' ? valueRaw + '' : valueRaw // Split into the lines - const elms = [] + const elms: Array = [] const lines = value.split('\\n') - for (const i in lines) { - const l = lines[i] + lines.forEach((l, i) => { elms.push(l) if (i <= lines.length - 1) { elms.push(
) } - } + }) return (
@@ -165,11 +186,15 @@ const VariablesTableRow = memo(function VariablesTableRow({ variable, value, lab @@ -289,6 +351,42 @@ function ActionRowDropPlaceholder({ dragId, stepId, setId, actionCount, moveCard ) } +interface ActionTableRowDragItem { + actionId: string + stepId: string + setId: string | number + index: number +} +interface ActionTableRowDragStatus { + isDragging: boolean +} + +interface ActionTableRowProps { + action: ActionInstance + stepId: string + setId: string | number + location: ControlLocation | undefined + index: number + dragId: string + setValue: (actionId: string, key: string, val: any) => void + doDelete: (actionId: string) => void + doDuplicate: (actionId: string) => void + doDelay: (actionId: string, delay: number) => void + moveCard: ( + stepId: string, + setId: string | number, + index: number, + targetStepId: string, + targetSetId: string | number, + targetIndex: number + ) => void + doLearn: ((actionId: string) => void) | undefined + doEnabled: ((actionId: string, enabled: boolean) => void) | undefined + readonly: boolean + isCollapsed: boolean + setCollapsed: (actionId: string, collapsed: boolean) => void +} + function ActionTableRow({ action, stepId, @@ -296,7 +394,6 @@ function ActionTableRow({ location, index, dragId, - controlId, setValue, doDelete, doDuplicate, @@ -307,24 +404,27 @@ function ActionTableRow({ readonly, isCollapsed, setCollapsed, -}) { +}: ActionTableRowProps): JSX.Element | null { const connectionsContext = useContext(ConnectionsContext) const actionsContext = useContext(ActionsContext) const innerDelete = useCallback(() => doDelete(action.id), [action.id, doDelete]) const innerDuplicate = useCallback(() => doDuplicate(action.id), [action.id, doDuplicate]) const innerDelay = useCallback((delay) => doDelay(action.id, delay), [doDelay, action.id]) - const innerLearn = useCallback(() => doLearn(action.id), [doLearn, action.id]) - const innerSetEnabled = useCallback((e) => doEnabled(action.id, e.target.checked), [doEnabled, action.id]) - - const [optionVisibility, setOptionVisibility] = useState({}) + const innerLearn = useCallback(() => doLearn && doLearn(action.id), [doLearn, action.id]) + const innerSetEnabled = useCallback( + (e) => doEnabled && doEnabled(action.id, e.target.checked), + [doEnabled, action.id] + ) const actionSpec = (actionsContext[action.instance] || {})[action.action] - const ref = useRef(null) - const [, drop] = useDrop({ + const [actionOptions, optionVisibility] = useOptionsAndIsVisible(actionSpec, action) + + const ref = useRef(null) + const [, drop] = useDrop({ accept: dragId, - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -346,7 +446,7 @@ function ActionTableRow({ item.setId = setId }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: dragId, canDrag: !readonly, item: { @@ -362,45 +462,6 @@ function ActionTableRow({ }) preview(drop(ref)) - useEffect(() => { - const options = actionSpec?.options ?? [] - - for (const option of options) { - try { - if (typeof option.isVisibleFn === 'string' && typeof option.isVisible !== 'function') { - option.isVisible = sandbox(option.isVisibleFn) - } - } catch (e) { - console.error('Failed to process isVisibleFn', e) - } - } - }, [actionSpec]) - - useEffect(() => { - const visibility = {} - const options = actionSpec?.options ?? [] - - if (options === null || action === null) { - return - } - - for (const option of options) { - try { - if (typeof option.isVisible === 'function') { - visibility[option.id] = option.isVisible(action.options, option.isVisibleData) - } - } catch (e) { - console.error('Failed to check visibility', e) - } - } - - setOptionVisibility(visibility) - - return () => { - setOptionVisibility({}) - } - }, [actionSpec, action]) - const doCollapse = useCallback(() => { setCollapsed(action.id, true) }, [setCollapsed, action.id]) @@ -410,23 +471,18 @@ function ActionTableRow({ if (!action) { // Invalid action, so skip - return '' + return null } const connectionInfo = connectionsContext[action.instance] // const module = instance ? modules[instance.instance_type] : undefined const connectionLabel = connectionInfo?.label ?? action.instance - const options = actionSpec?.options ?? [] - const showButtonPreview = action?.instance === 'internal' && actionSpec?.showButtonPreview - let name = '' - if (actionSpec) { - name = `${connectionLabel}: ${actionSpec.label}` - } else { - name = `${connectionLabel}: ${action.action} (undefined)` - } + const name = actionSpec + ? `${connectionLabel}: ${actionSpec.label}` + : `${connectionLabel}: ${action.action} (undefined)` return ( @@ -454,7 +510,7 @@ function ActionTableRow({ - {doEnabled && ( + {!!doEnabled && ( <>   - {options.map((opt, i) => ( + {actionOptions.map((opt, i) => ( { +const baseFilter = createFilter() +const filterOptions = (candidate: FilterOptionOption, input: string): boolean => { if (input) { return !candidate.data.isRecent && baseFilter(candidate, input) } else { @@ -541,7 +597,7 @@ const filterOptions = (candidate, input) => { } } -const noOptionsMessage = ({ inputValue }) => { +const noOptionsMessage = ({ inputValue }: { inputValue: string }) => { if (inputValue) { return 'No actions found' } else { @@ -549,16 +605,32 @@ const noOptionsMessage = ({ inputValue }) => { } } -function AddActionDropdown({ onSelect, placeholder }) { +interface AddActionOption { + isRecent: boolean + value: string + label: string +} +interface AddActionGroup { + label: string + options: AddActionOption[] +} + +interface AddActionDropdownProps { + onSelect: (actionType: string) => void + placeholder: string +} + +function AddActionDropdown({ onSelect, placeholder }: AddActionDropdownProps) { const recentActionsContext = useContext(RecentActionsContext) const menuPortal = useContext(MenuPortalContext) const connectionsContext = useContext(ConnectionsContext) const actionsContext = useContext(ActionsContext) const options = useMemo(() => { - const options = [] + const options: Array = [] for (const [connectionId, connectionActions] of Object.entries(actionsContext)) { for (const [actionId, action] of Object.entries(connectionActions || {})) { + if (!action) continue const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId options.push({ isRecent: false, @@ -568,8 +640,8 @@ function AddActionDropdown({ onSelect, placeholder }) { } } - const recents = [] - for (const actionType of recentActionsContext.recentActions) { + const recents: AddActionOption[] = [] + for (const actionType of recentActionsContext?.recentActions ?? []) { if (actionType) { const [connectionId, actionId] = actionType.split(':', 2) const actionInfo = actionsContext[connectionId]?.[actionId] @@ -589,12 +661,12 @@ function AddActionDropdown({ onSelect, placeholder }) { }) return options - }, [actionsContext, connectionsContext, recentActionsContext.recentActions]) + }, [actionsContext, connectionsContext, recentActionsContext?.recentActions]) const innerChange = useCallback( - (e) => { - if (e.value) { - recentActionsContext.trackRecentAction(e.value) + (e: AddActionOption | null) => { + if (e?.value) { + recentActionsContext?.trackRecentAction(e.value) onSelect(e.value) } diff --git a/webui/src/Controls/AddModal.jsx b/webui/src/Controls/AddModal.tsx similarity index 74% rename from webui/src/Controls/AddModal.jsx rename to webui/src/Controls/AddModal.tsx index 1f81bd5582..e0d125fb7e 100644 --- a/webui/src/Controls/AddModal.jsx +++ b/webui/src/Controls/AddModal.tsx @@ -18,8 +18,19 @@ import { RecentActionsContext, RecentFeedbacksContext, } from '../util' +import { ClientConnectionConfig } from '@companion/shared/Model/Common' -export const AddActionsModal = forwardRef(function AddActionsModal({ addAction }, ref) { +interface AddActionsModalProps { + addAction: (actionType: string) => void +} +export interface AddActionsModalRef { + show(): void +} + +export const AddActionsModal = forwardRef(function AddActionsModal( + { addAction }, + ref +) { const recentActionsContext = useContext(RecentActionsContext) const actions = useContext(ActionsContext) const connections = useContext(ConnectionsContext) @@ -42,8 +53,8 @@ export const AddActionsModal = forwardRef(function AddActionsModal({ addAction } [] ) - const [expanded, setExpanded] = useState({}) - const toggle = useCallback((id) => { + const [expanded, setExpanded] = useState>({}) + const toggleExpanded = useCallback((id: string) => { setExpanded((oldVal) => { return { ...oldVal, @@ -54,8 +65,8 @@ export const AddActionsModal = forwardRef(function AddActionsModal({ addAction } const [filter, setFilter] = useState('') const addAction2 = useCallback( - (actionType) => { - recentActionsContext.trackRecentAction(actionType) + (actionType: string) => { + recentActionsContext?.trackRecentAction(actionType) addAction(actionType) }, @@ -87,7 +98,7 @@ export const AddActionsModal = forwardRef(function AddActionsModal({ addAction } itemName="actions" expanded={!!filter || expanded[connectionId]} filter={filter} - doToggle={toggle} + doToggle={toggleExpanded} doAdd={addAction2} /> ))} @@ -101,7 +112,18 @@ export const AddActionsModal = forwardRef(function AddActionsModal({ addAction } ) }) -export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeedback, booleanOnly }, ref) { +interface AddFeedbacksModalProps { + addFeedback: (feedbackType: string) => void + booleanOnly: boolean +} +export interface AddFeedbacksModalRef { + show(): void +} + +export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal( + { addFeedback, booleanOnly }, + ref +) { const recentFeedbacksContext = useContext(RecentFeedbacksContext) const feedbacks = useContext(FeedbacksContext) const connections = useContext(ConnectionsContext) @@ -124,8 +146,8 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed [] ) - const [expanded, setExpanded] = useState({}) - const toggle = useCallback((id) => { + const [expanded, setExpanded] = useState>({}) + const toggleExpanded = useCallback((id: string) => { setExpanded((oldVal) => { return { ...oldVal, @@ -137,7 +159,7 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed const addFeedback2 = useCallback( (feedbackType) => { - recentFeedbacksContext.trackRecentFeedback(feedbackType) + recentFeedbacksContext?.trackRecentFeedback(feedbackType) addFeedback(feedbackType) }, @@ -169,7 +191,7 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed expanded={!!filter || expanded[connectionId]} filter={filter} booleanOnly={booleanOnly} - doToggle={toggle} + doToggle={toggleExpanded} doAdd={addFeedback2} /> ))} @@ -183,6 +205,18 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed ) }) +interface ConnectionCollapseProps { + connectionId: string + connectionInfo: ClientConnectionConfig | undefined + items: Record | undefined + itemName: string + expanded: boolean + filter: string + booleanOnly?: boolean + doToggle: (connectionId: string) => void + doAdd: (itemId: string) => void +} + function ConnectionCollapse({ connectionId, connectionInfo, @@ -193,7 +227,7 @@ function ConnectionCollapse({ booleanOnly, doToggle, doAdd, -}) { +}: ConnectionCollapseProps) { const doToggle2 = useCallback(() => doToggle(connectionId), [doToggle, connectionId]) const candidates = useMemo(() => { @@ -201,8 +235,8 @@ function ConnectionCollapse({ const regexp = new RegExp(filter, 'i') const res = [] - for (const [id, info] of Object.entries(items)) { - if (booleanOnly && info.type !== 'boolean') continue + for (const [id, info] of Object.entries(items ?? {})) { + if (!info || (booleanOnly && info.type !== 'boolean')) continue if (info.label?.match(regexp)) { const fullId = `${connectionId}:${id}` @@ -230,9 +264,9 @@ function ConnectionCollapse({ } }, [items, filter, connectionId, itemName, booleanOnly]) - if (Object.keys(items).length === 0) { + if (!items || Object.keys(items).length === 0) { // Hide card if there are no actions which match - return '' + return null } else { return ( @@ -261,7 +295,12 @@ function ConnectionCollapse({ } } -function AddRow({ info, id, doAdd }) { +interface AddRowProps { + info: { label: string; description?: string } + id: string + doAdd: (itemId: string) => void +} +function AddRow({ info, id, doAdd }: AddRowProps) { const doAdd2 = useCallback(() => doAdd(id), [doAdd, id]) return ( diff --git a/webui/src/Controls/ButtonStyleConfig.jsx b/webui/src/Controls/ButtonStyleConfig.tsx similarity index 73% rename from webui/src/Controls/ButtonStyleConfig.jsx rename to webui/src/Controls/ButtonStyleConfig.tsx index 3a3e4871b6..6c18064a67 100644 --- a/webui/src/Controls/ButtonStyleConfig.jsx +++ b/webui/src/Controls/ButtonStyleConfig.tsx @@ -1,15 +1,31 @@ import { CButton, CRow, CCol, CButtonGroup, CForm, CAlert, CInputGroup, CInputGroupAppend } from '@coreui/react' -import React, { useCallback, useContext, useMemo, useState } from 'react' +import React, { MutableRefObject, useCallback, useContext, useMemo, useState } from 'react' import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' import { AlignmentInputField, ColorInputField, DropdownInputField, PNGInputField, TextInputField } from '../Components' import { FONT_SIZES, SHOW_HIDE_TOP_BAR } from '../Constants' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, faFont, faQuestionCircle, faTrash } from '@fortawesome/free-solid-svg-icons' +import { SomeButtonModel } from '@companion/shared/Model/ButtonModel' +import { ButtonStyleProperties } from '@companion/shared/Model/StyleModel' -export function ButtonStyleConfig({ controlId, controlType, style, configRef, mainDialog = false }) { +interface ButtonStyleConfigProps { + controlId: string + controlType: string + style: Record + configRef: MutableRefObject + mainDialog?: boolean +} + +export function ButtonStyleConfig({ + controlId, + controlType, + style, + configRef, + mainDialog = false, +}: ButtonStyleConfigProps) { const socket = useContext(SocketContext) - const [pngError, setPngError] = useState(null) + const [pngError, setPngError] = useState(null) const setPng = useCallback( (data) => { setPngError(null) @@ -27,8 +43,12 @@ export function ButtonStyleConfig({ controlId, controlType, style, configRef, ma ) const setValueInner = useCallback( - (key, value) => { - if (configRef.current === undefined || value !== configRef.current.style[key]) { + (key: string, value: any) => { + const currentConfig = configRef.current + if ( + !currentConfig || + (currentConfig.type === 'button' && value !== currentConfig.style[key as keyof ButtonStyleProperties]) + ) { socketEmitPromise(socket, 'controls:set-style-fields', [ controlId, { @@ -103,6 +123,16 @@ export function ButtonStyleConfig({ controlId, controlType, style, configRef, ma ) } +interface ButtonStyleConfigFieldsProps { + values: Record + setValueInner: (key: string, value: any) => void + setPng: (png64: string | null) => void + setPngError: (error: string | null) => void + clearPng: () => void + mainDialog?: boolean + showField?: (key: string) => boolean +} + export function ButtonStyleConfigFields({ values, setValueInner, @@ -111,14 +141,14 @@ export function ButtonStyleConfigFields({ clearPng, mainDialog, showField, -}) { - const setTextValue = useCallback((val) => setValueInner('text', val), [setValueInner]) - const setSizeValue = useCallback((val) => setValueInner('size', val), [setValueInner]) - const setAlignmentValue = useCallback((val) => setValueInner('alignment', val), [setValueInner]) - const setPngAlignmentValue = useCallback((val) => setValueInner('pngalignment', val), [setValueInner]) - const setColorValue = useCallback((val) => setValueInner('color', val), [setValueInner]) - const setBackgroundColorValue = useCallback((val) => setValueInner('bgcolor', val), [setValueInner]) - const setShowTopBar = useCallback((val) => setValueInner('show_topbar', val), [setValueInner]) +}: ButtonStyleConfigFieldsProps) { + const setTextValue = useCallback((val: any) => setValueInner('text', val), [setValueInner]) + const setSizeValue = useCallback((val: any) => setValueInner('size', val), [setValueInner]) + const setAlignmentValue = useCallback((val: any) => setValueInner('alignment', val), [setValueInner]) + const setPngAlignmentValue = useCallback((val: any) => setValueInner('pngalignment', val), [setValueInner]) + const setColorValue = useCallback((val: any) => setValueInner('color', val), [setValueInner]) + const setBackgroundColorValue = useCallback((val: any) => setValueInner('bgcolor', val), [setValueInner]) + const setShowTopBar = useCallback((val: any) => setValueInner('show_topbar', val), [setValueInner]) const toggleExpression = useCallback( () => setValueInner('textExpression', !values.textExpression), [setValueInner, values.textExpression] @@ -130,7 +160,7 @@ export function ButtonStyleConfigFields({ [mainDialog] ) - const showField2 = (id) => !showField || showField(id) + const showField2 = (id: string) => !showField || showField(id) return ( <> @@ -183,6 +213,7 @@ export function ButtonStyleConfigFields({ value={values.size} allowCustom={true} regex={'/^0*(?:[3-9]|[1-9][0-9]|1[0-9]{2}|200)\\s?(?:pt|px)?$/i'} + multiple={false} /> @@ -192,13 +223,13 @@ export function ButtonStyleConfigFields({ {showField2('color') && (
- +
)} {showField2('bgcolor') && (
- +
)} @@ -206,7 +237,12 @@ export function ButtonStyleConfigFields({ {showField2('show_topbar') && (
- +
)} diff --git a/webui/src/Controls/ControlOptionsEditor.jsx b/webui/src/Controls/ControlOptionsEditor.tsx similarity index 82% rename from webui/src/Controls/ControlOptionsEditor.jsx rename to webui/src/Controls/ControlOptionsEditor.tsx index 54f97bebcb..8863f888c4 100644 --- a/webui/src/Controls/ControlOptionsEditor.jsx +++ b/webui/src/Controls/ControlOptionsEditor.tsx @@ -1,18 +1,30 @@ import { CLabel } from '@coreui/react' -import React, { useCallback, useContext, useRef } from 'react' +import React, { MutableRefObject, useCallback, useContext, useRef } from 'react' import { socketEmitPromise, SocketContext } from '../util' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' -export function ControlOptionsEditor({ controlId, controlType, options, configRef }) { +interface ControlOptionsEditorProps { + controlId: string + controlType: string + options: Record // TODO + configRef: MutableRefObject // TODO +} + +export function ControlOptionsEditor({ + controlId, + controlType, + options, + configRef, +}: ControlOptionsEditorProps): JSX.Element | null { const socket = useContext(SocketContext) - const confirmRef = useRef(null) + const confirmRef = useRef(null) const setValueInner = useCallback( - (key, value) => { + (key: string, value: any) => { if (configRef.current === undefined || value !== configRef.current.options[key]) { socketEmitPromise(socket, 'controls:set-options-field', [controlId, key, value]).catch((e) => { console.error(`Set field failed: ${e}`) @@ -25,7 +37,7 @@ export function ControlOptionsEditor({ controlId, controlType, options, configRe const setStepAutoProgressValue = useCallback((val) => setValueInner('stepAutoProgress', val), [setValueInner]) const setRelativeDelayValue = useCallback((val) => setValueInner('relativeDelay', val), [setValueInner]) const setRotaryActions = useCallback( - (val) => { + (val: boolean) => { if (!val && confirmRef.current && configRef.current && configRef.current.options.rotaryActions === true) { confirmRef.current.show( 'Disable rotary actions', @@ -44,13 +56,13 @@ export function ControlOptionsEditor({ controlId, controlType, options, configRe switch (controlType) { case undefined: - return '' + return null case 'pageup': - return '' + return null case 'pagenum': - return '' + return null case 'pagedown': - return '' + return null default: // See below } diff --git a/webui/src/Controls/FeedbackEditor.jsx b/webui/src/Controls/FeedbackEditor.tsx similarity index 75% rename from webui/src/Controls/FeedbackEditor.jsx rename to webui/src/Controls/FeedbackEditor.tsx index c496723cea..0e178d425e 100644 --- a/webui/src/Controls/FeedbackEditor.jsx +++ b/webui/src/Controls/FeedbackEditor.tsx @@ -9,13 +9,12 @@ import { faQuestionCircle, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useMemo, useRef, useState } from 'react' import { FeedbacksContext, ConnectionsContext, MyErrorBoundary, socketEmitPromise, - sandbox, SocketContext, PreventDefaultHandler, RecentFeedbacksContext, @@ -23,14 +22,30 @@ import { import Select, { createFilter } from 'react-select' import { OptionsInputField } from './OptionsInputField' import { useDrag, useDrop } from 'react-dnd' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { CheckboxInputField, DropdownInputField } from '../Components' import { ButtonStyleConfigFields } from './ButtonStyleConfig' -import { AddFeedbacksModal } from './AddModal' +import { AddFeedbacksModal, AddFeedbacksModalRef } from './AddModal' import { usePanelCollapseHelper } from '../Helpers/CollapseHelper' import { OptionButtonPreview } from './OptionButtonPreview' import { MenuPortalContext } from '../Components/DropdownInputField' import { ButtonStyleProperties } from '@companion/shared/Style' +import { FilterOptionOption } from 'react-select/dist/declarations/src/filters' +import { FeedbackInstance } from '@companion/shared/Model/FeedbackModel' +import { FeedbackDefinition } from '@companion/shared/Model/Options' +import { DropdownChoiceId } from '@companion-module/base' +import { ControlLocation } from '@companion/shared/Model/Common' +import { useOptionsAndIsVisible } from '../Hooks/useOptionsAndIsVisible' + +interface ControlFeedbacksEditorProps { + controlId: string + feedbacks: FeedbackInstance[] + heading: JSX.Element | string + entityType: string + booleanOnly: boolean + location: ControlLocation | undefined + addPlaceholder: string +} export function ControlFeedbacksEditor({ controlId, @@ -40,23 +55,21 @@ export function ControlFeedbacksEditor({ booleanOnly, location, addPlaceholder, -}) { +}: ControlFeedbacksEditorProps) { const socket = useContext(SocketContext) - const confirmModal = useRef() + const confirmModal = useRef(null) - const feedbacksRef = useRef() + const feedbacksRef = useRef() feedbacksRef.current = feedbacks - const addFeedbacksRef = useRef(null) + const addFeedbacksRef = useRef(null) const showAddModal = useCallback(() => { - if (addFeedbacksRef.current) { - addFeedbacksRef.current.show() - } + addFeedbacksRef.current?.show() }, []) const setValue = useCallback( - (feedbackId, key, val) => { + (feedbackId: string, key: string, val: any) => { const currentFeedback = feedbacksRef.current?.find((fb) => fb.id === feedbackId) if (!currentFeedback?.options || currentFeedback.options[key] !== val) { socketEmitPromise(socket, 'controls:feedback:set-option', [controlId, feedbackId, key, val]).catch((e) => { @@ -67,7 +80,7 @@ export function ControlFeedbacksEditor({ [socket, controlId] ) const setInverted = useCallback( - (feedbackId, isInverted) => { + (feedbackId: string, isInverted: boolean) => { const currentFeedback = feedbacksRef.current?.find((fb) => fb.id === feedbackId) if (!currentFeedback || currentFeedback.isInverted !== isInverted) { socketEmitPromise(socket, 'controls:feedback:set-inverted', [controlId, feedbackId, isInverted]).catch((e) => { @@ -79,8 +92,8 @@ export function ControlFeedbacksEditor({ ) const doDelete = useCallback( - (feedbackId) => { - confirmModal.current.show(`Delete ${entityType}`, `Delete ${entityType}?`, 'Delete', () => { + (feedbackId: string) => { + confirmModal.current?.show(`Delete ${entityType}`, `Delete ${entityType}?`, 'Delete', () => { socketEmitPromise(socket, 'controls:feedback:remove', [controlId, feedbackId]).catch((e) => { console.error(`Failed to delete feedback: ${e}`) }) @@ -90,7 +103,7 @@ export function ControlFeedbacksEditor({ ) const doDuplicate = useCallback( - (feedbackId) => { + (feedbackId: string) => { socketEmitPromise(socket, 'controls:feedback:duplicate', [controlId, feedbackId]).catch((e) => { console.error(`Failed to duplicate feedback: ${e}`) }) @@ -99,7 +112,7 @@ export function ControlFeedbacksEditor({ ) const doLearn = useCallback( - (feedbackId) => { + (feedbackId: string) => { socketEmitPromise(socket, 'controls:feedback:learn', [controlId, feedbackId]).catch((e) => { console.error(`Failed to learn feedback values: ${e}`) }) @@ -108,7 +121,7 @@ export function ControlFeedbacksEditor({ ) const addFeedback = useCallback( - (feedbackType) => { + (feedbackType: string) => { const [connectionId, feedbackId] = feedbackType.split(':', 2) socketEmitPromise(socket, 'controls:feedback:add', [controlId, connectionId, feedbackId]).catch((e) => { console.error('Failed to add control feedback', e) @@ -118,7 +131,7 @@ export function ControlFeedbacksEditor({ ) const moveCard = useCallback( - (dragIndex, hoverIndex) => { + (dragIndex: number, hoverIndex: number) => { socketEmitPromise(socket, 'controls:feedback:reorder', [controlId, dragIndex, hoverIndex]).catch((e) => { console.error(`Move failed: ${e}`) }) @@ -127,7 +140,7 @@ export function ControlFeedbacksEditor({ ) const emitEnabled = useCallback( - (feedbackId, enabled) => { + (feedbackId: string, enabled: boolean) => { socketEmitPromise(socket, 'controls:feedback:enabled', [controlId, feedbackId, enabled]).catch((e) => { console.error('Failed to enable/disable feedback', e) }) @@ -212,6 +225,33 @@ export function ControlFeedbacksEditor({ ) } +interface FeedbackTableRowDragItem { + feedbackId: string + index: number +} +interface FeedbackTableRowDragStatus { + isDragging: boolean +} + +interface FeedbackTableRowProps { + entityType: string + feedback: FeedbackInstance + controlId: string + index: number + dragId: string + moveCard: (dragIndex: number, hoverIndex: number) => void + setValue: (feedbackId: string, key: string, val: any) => void + setInverted: (feedbackId: string, inverted: boolean) => void + doDelete: (feedbackId: string) => void + doDuplicate: (feedbackId: string) => void + doLearn: (feedbackId: string) => void + doEnabled: (feedbackId: string, enabled: boolean) => void + isCollapsed: boolean + setCollapsed: (feedbackId: string, collapsed: boolean) => void + booleanOnly: boolean + location: ControlLocation | undefined +} + function FeedbackTableRow({ entityType, feedback, @@ -229,7 +269,7 @@ function FeedbackTableRow({ setCollapsed, booleanOnly, location, -}) { +}: FeedbackTableRowProps) { const socket = useContext(SocketContext) const innerDelete = useCallback(() => doDelete(feedback.id), [feedback.id, doDelete]) @@ -237,10 +277,10 @@ function FeedbackTableRow({ const innerLearn = useCallback(() => doLearn(feedback.id), [doLearn, feedback.id]) const innerInverted = useCallback((isInverted) => setInverted(feedback.id, isInverted), [feedback.id, setInverted]) - const ref = useRef(null) - const [, drop] = useDrop({ + const ref = useRef(null) + const [, drop] = useDrop({ accept: dragId, - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -261,10 +301,10 @@ function FeedbackTableRow({ item.index = hoverIndex }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: dragId, item: { - actionId: feedback.id, + feedbackId: feedback.id, index: index, }, collect: (monitor) => ({ @@ -274,7 +314,7 @@ function FeedbackTableRow({ preview(drop(ref)) const setSelectedStyleProps = useCallback( - (selected) => { + (selected: string[]) => { socketEmitPromise(socket, 'controls:feedback:set-style-selection', [controlId, feedback.id, selected]).catch( (e) => { console.error(`Failed: ${e}`) @@ -285,7 +325,7 @@ function FeedbackTableRow({ ) const setStylePropsValue = useCallback( - (key, value) => { + (key: string, value: any) => { socketEmitPromise(socket, 'controls:feedback:set-style-value', [controlId, feedback.id, key, value]).catch( (e) => { console.error(`Failed: ${e}`) @@ -304,7 +344,7 @@ function FeedbackTableRow({ if (!feedback) { // Invalid feedback, so skip - return '' + return null } return ( @@ -335,6 +375,24 @@ function FeedbackTableRow({ ) } +interface FeedbackEditorProps { + entityType: string + feedback: FeedbackInstance + location: ControlLocation | undefined + setValue: (feedbackId: string, key: string, value: any) => void + setInverted: (inverted: boolean) => void + innerDelete: () => void + innerDuplicate: () => void + innerLearn: () => void + setSelectedStyleProps: (keys: string[]) => void + setStylePropsValue: (key: string, value: any) => void + isCollapsed: boolean + doCollapse: () => void + doExpand: () => void + doEnabled: (feedbackId: string, enabled: boolean) => void + booleanOnly: boolean +} + function FeedbackEditor({ entityType, feedback, @@ -351,7 +409,7 @@ function FeedbackEditor({ doExpand, doEnabled, booleanOnly, -}) { +}: FeedbackEditorProps) { const feedbacksContext = useContext(FeedbacksContext) const connectionsContext = useContext(ConnectionsContext) @@ -359,49 +417,14 @@ function FeedbackEditor({ const connectionLabel = connectionInfo?.label ?? feedback.instance_id const feedbackSpec = (feedbacksContext[feedback.instance_id] || {})[feedback.type] - const options = feedbackSpec?.options ?? [] - const [optionVisibility, setOptionVisibility] = useState({}) + const [feedbackOptions, optionVisibility] = useOptionsAndIsVisible(feedbackSpec, feedback) const innerSetEnabled = useCallback((e) => doEnabled(feedback.id, e.target.checked), [doEnabled, feedback.id]) - useEffect(() => { - const options = feedbackSpec?.options ?? [] - - for (const option of options) { - if (typeof option.isVisibleFn === 'string') { - option.isVisible = sandbox(option.isVisibleFn) - } - } - }, [feedbackSpec]) - - useEffect(() => { - const visibility = {} - const options = feedbackSpec?.options ?? [] - - if (options === null || feedback === null) { - return - } - - for (const option of options) { - if (typeof option.isVisible === 'function') { - visibility[option.id] = option.isVisible(feedback.options, option.isVisibleData) - } - } - - setOptionVisibility(visibility) - - return () => { - setOptionVisibility({}) - } - }, [feedbackSpec, feedback]) - - let name = '' - if (feedbackSpec) { - name = `${connectionLabel}: ${feedbackSpec.label}` - } else { - name = `${connectionLabel}: ${feedback.type} (undefined)` - } + const name = feedbackSpec + ? `${connectionLabel}: ${feedbackSpec.label}` + : `${connectionLabel}: ${feedback.type} (undefined)` const showButtonPreview = feedback?.instance_id === 'internal' && feedbackSpec?.showButtonPreview @@ -427,7 +450,7 @@ function FeedbackEditor({ - {doEnabled && ( + {!!doEnabled && ( <>   - {options.map((opt, i) => ( + {feedbackOptions.map((opt, i) => (

- +  

@@ -518,7 +542,13 @@ function FeedbackEditor({ ) } -function FeedbackManageStyles({ feedbackSpec, feedback, setSelectedStyleProps }) { +interface FeedbackManageStylesProps { + feedbackSpec: FeedbackDefinition | undefined + feedback: FeedbackInstance + setSelectedStyleProps: (keys: string[]) => void +} + +function FeedbackManageStyles({ feedbackSpec, feedback, setSelectedStyleProps }: FeedbackManageStylesProps) { if (feedbackSpec?.type === 'boolean') { const choicesSet = new Set(ButtonStyleProperties.map((c) => c.id)) const currentValue = Object.keys(feedback.style || {}).filter((id) => choicesSet.has(id)) @@ -532,7 +562,7 @@ function FeedbackManageStyles({ feedbackSpec, feedback, setSelectedStyleProps }) void} value={currentValue} /> @@ -541,37 +571,29 @@ function FeedbackManageStyles({ feedbackSpec, feedback, setSelectedStyleProps }) ) } else { - return '' + return null } } -function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }) { - const setValue = useCallback( - (key, value) => { - setStylePropsValue(key, value).catch((e) => { - console.error('Failed to update feedback style', e) - }) - }, - [setStylePropsValue] - ) - const [pngError, setPngError] = useState(null) +interface FeedbackStylesProps { + feedbackSpec: FeedbackDefinition | undefined + feedback: FeedbackInstance + setStylePropsValue: (key: string, value: any) => void +} + +function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }: FeedbackStylesProps) { + const [pngError, setPngError] = useState(null) const clearPngError = useCallback(() => setPngError(null), []) const setPng = useCallback( (data) => { setPngError(null) - setStylePropsValue('png64', data).catch((e) => { - console.error('Failed to upload png', e) - setPngError('Failed to set png') - }) + setStylePropsValue('png64', data) }, [setStylePropsValue] ) const clearPng = useCallback(() => { setPngError(null) - setStylePropsValue('png64', null).catch((e) => { - console.error('Failed to clear png', e) - setPngError('Failed to clear png') - }) + setStylePropsValue('png64', null) }, [setStylePropsValue]) const currentStyle = useMemo(() => feedback?.style || {}, [feedback?.style]) @@ -589,7 +611,7 @@ function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }) { ) } else { - return '' + return null } } -const baseFilter = createFilter() -const filterOptions = (candidate, input) => { +const baseFilter = createFilter() +const filterOptions = (candidate: FilterOptionOption, input: string) => { if (input) { return !candidate.data.isRecent && baseFilter(candidate, input) } else { @@ -613,7 +635,7 @@ const filterOptions = (candidate, input) => { } } -const noOptionsMessage = ({ inputValue }) => { +const noOptionsMessage = ({ inputValue }: { inputValue: string }) => { if (inputValue) { return 'No feedbacks found' } else { @@ -621,16 +643,37 @@ const noOptionsMessage = ({ inputValue }) => { } } -const AddFeedbackDropdown = memo(function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { +interface AddFeedbackOption { + isRecent: boolean + value: string + label: string +} +interface AddFeedbackGroup { + label: string + options: AddFeedbackOption[] +} + +interface AddFeedbackDropdownProps { + onSelect: (feedbackType: string) => void + booleanOnly: boolean + addPlaceholder: string +} + +const AddFeedbackDropdown = memo(function AddFeedbackDropdown({ + onSelect, + booleanOnly, + addPlaceholder, +}: AddFeedbackDropdownProps) { const recentFeedbacksContext = useContext(RecentFeedbacksContext) const menuPortal = useContext(MenuPortalContext) const feedbacksContext = useContext(FeedbacksContext) const connectionsContext = useContext(ConnectionsContext) const options = useMemo(() => { - const options = [] + const options: Array = [] for (const [connectionId, instanceFeedbacks] of Object.entries(feedbacksContext)) { for (const [feedbackId, feedback] of Object.entries(instanceFeedbacks || {})) { + if (!feedback) continue if (!booleanOnly || feedback.type === 'boolean') { const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId options.push({ @@ -642,8 +685,8 @@ const AddFeedbackDropdown = memo(function AddFeedbackDropdown({ onSelect, boolea } } - const recents = [] - for (const feedbackType of recentFeedbacksContext.recentFeedbacks || []) { + const recents: AddFeedbackOption[] = [] + for (const feedbackType of recentFeedbacksContext?.recentFeedbacks ?? []) { if (feedbackType) { const [connectionId, feedbackId] = feedbackType.split(':', 2) const feedbackInfo = feedbacksContext[connectionId]?.[feedbackId] @@ -663,12 +706,12 @@ const AddFeedbackDropdown = memo(function AddFeedbackDropdown({ onSelect, boolea }) return options - }, [feedbacksContext, connectionsContext, booleanOnly, recentFeedbacksContext.recentFeedbacks]) + }, [feedbacksContext, connectionsContext, booleanOnly, recentFeedbacksContext?.recentFeedbacks]) const innerChange = useCallback( - (e) => { - if (e.value) { - recentFeedbacksContext.trackRecentFeedback(e.value) + (e: AddFeedbackOption | null) => { + if (e?.value) { + recentFeedbacksContext?.trackRecentFeedback(e.value) onSelect(e.value) } diff --git a/webui/src/Controls/InternalInstanceFields.jsx b/webui/src/Controls/InternalInstanceFields.tsx similarity index 69% rename from webui/src/Controls/InternalInstanceFields.jsx rename to webui/src/Controls/InternalInstanceFields.tsx index b90d962423..b159f9920b 100644 --- a/webui/src/Controls/InternalInstanceFields.jsx +++ b/webui/src/Controls/InternalInstanceFields.tsx @@ -9,8 +9,16 @@ import { VariableDefinitionsContext, } from '../util' import TimePicker from 'react-time-picker' +import { InternalInputField } from '@companion/shared/Model/Options' +import { DropdownChoice } from '@companion-module/base' -export function InternalInstanceField(option, isOnControl, readonly, value, setValue) { +export function InternalInstanceField( + option: InternalInputField, + isOnControl: boolean, + readonly: boolean, + value: any, + setValue: (value: any) => void +): JSX.Element | null { switch (option.type) { case 'internal:instance_id': return ( @@ -69,11 +77,27 @@ export function InternalInstanceField(option, isOnControl, readonly, value, setV return default: // Use fallback - return undefined + return null } } -function InternalInstanceIdDropdown({ includeAll, value, setValue, disabled, multiple, filterActionsRecorder }) { +interface InternalInstanceIdDropdownProps { + includeAll: boolean | undefined + value: any + setValue: (value: any) => void + disabled: boolean + multiple: boolean + filterActionsRecorder: boolean | undefined +} + +function InternalInstanceIdDropdown({ + includeAll, + value, + setValue, + disabled, + multiple, + filterActionsRecorder, +}: InternalInstanceIdDropdownProps) { const context = useContext(ConnectionsContext) const choices = useMemo(() => { @@ -95,11 +119,19 @@ function InternalInstanceIdDropdown({ includeAll, value, setValue, disabled, mul ) } -function InternalPageDropdown({ isOnControl, includeDirection, value, setValue, disabled }) { +interface InternalPageDropdownProps { + isOnControl: boolean + includeDirection: boolean | undefined + value: any + setValue: (value: any) => void + disabled: boolean +} + +function InternalPageDropdown({ isOnControl, includeDirection, value, setValue, disabled }: InternalPageDropdownProps) { const pages = useContext(PagesContext) const choices = useMemo(() => { - const choices = [] + const choices: DropdownChoice[] = [] if (isOnControl) { choices.push({ id: 0, label: 'This page' }) } @@ -108,8 +140,8 @@ function InternalPageDropdown({ isOnControl, includeDirection, value, setValue, } for (let i = 1; i <= 99; i++) { - const name = pages[i] - choices.push({ id: i, label: `${i}` + (name ? ` (${name.name || ''})` : '') }) + const name = pages?.[i] + choices.push({ id: i, label: `${i}` + (name ? ` (${name?.name || ''})` : '') }) } return choices }, [pages, isOnControl, includeDirection]) @@ -117,7 +149,19 @@ function InternalPageDropdown({ isOnControl, includeDirection, value, setValue, return } -export function InternalCustomVariableDropdown({ value, setValue, includeNone, disabled }) { +interface InternalCustomVariableDropdownProps { + value: any + setValue: (value: any) => void + includeNone: boolean | undefined + disabled: boolean +} + +export function InternalCustomVariableDropdown({ + value, + setValue, + includeNone, + disabled, +}: InternalCustomVariableDropdownProps) { const context = useContext(CustomVariableDefinitionsContext) const choices = useMemo(() => { const choices = [] @@ -150,13 +194,20 @@ export function InternalCustomVariableDropdown({ value, setValue, includeNone, d ) } -function InternalVariableDropdown({ value, setValue, disabled }) { +interface InternalVariableDropdownProps { + value: any + setValue: (value: any) => void + disabled: boolean +} + +function InternalVariableDropdown({ value, setValue, disabled }: InternalVariableDropdownProps) { const context = useContext(VariableDefinitionsContext) const choices = useMemo(() => { const choices = [] for (const [connectionLabel, variables] of Object.entries(context)) { for (const [name, variable] of Object.entries(variables || {})) { + if (!variable) continue const id = `${connectionLabel}:${name}` choices.push({ id, @@ -182,17 +233,35 @@ function InternalVariableDropdown({ value, setValue, disabled }) { ) } -function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf, useRawSurfaces }) { +interface InternalSurfaceBySerialDropdownProps { + isOnControl: boolean + value: any + setValue: (value: any) => void + disabled: boolean + includeSelf: boolean | undefined + useRawSurfaces: boolean | undefined +} + +function InternalSurfaceBySerialDropdown({ + isOnControl, + value, + setValue, + disabled, + includeSelf, + useRawSurfaces, +}: InternalSurfaceBySerialDropdownProps) { const surfacesContext = useContext(SurfacesContext) const choices = useMemo(() => { - const choices = [] + const choices: DropdownChoice[] = [] if (isOnControl && includeSelf) { choices.push({ id: 'self', label: 'Current surface' }) } if (!useRawSurfaces) { for (const group of Object.values(surfacesContext ?? {})) { + if (!group) continue + choices.push({ label: group.displayName, id: group.id, @@ -200,6 +269,8 @@ function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disable } } else { for (const group of Object.values(surfacesContext ?? {})) { + if (!group) continue + for (const surface of group.surfaces) { choices.push({ label: surface.displayName, @@ -215,7 +286,21 @@ function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disable return } -function InternalTriggerDropdown({ isOnControl, value, setValue, disabled, includeSelf }) { +interface InternalTriggerDropdownProps { + isOnControl: boolean + value: any + setValue: (value: any) => void + disabled: boolean + includeSelf: boolean | undefined +} + +function InternalTriggerDropdown({ + isOnControl, + value, + setValue, + disabled, + includeSelf, +}: InternalTriggerDropdownProps) { const context = useContext(TriggersContext) const choices = useMemo(() => { @@ -225,6 +310,8 @@ function InternalTriggerDropdown({ isOnControl, value, setValue, disabled, inclu } for (const [id, trigger] of Object.entries(context)) { + if (!trigger) continue + choices.push({ id: id, label: trigger.name || `Trigger #${id}`, @@ -236,7 +323,13 @@ function InternalTriggerDropdown({ isOnControl, value, setValue, disabled, inclu return } -function InternalTimePicker({ value, setValue, disabled }) { +interface InternalTimePickerProps { + value: any + setValue: (value: any) => void + disabled: boolean +} + +function InternalTimePicker({ value, setValue, disabled }: InternalTimePickerProps) { return ( +} /** * Preview a button based on the selected options * @param {string} param.location where this preview is located (if any) * @returns */ -export function OptionButtonPreview({ location, options }) { +export function OptionButtonPreview({ location, options }: OptionButtonPreviewProps) { const socket = useContext(SocketContext) - const [image, setImage] = useState(null) + const [image, setImage] = useState(null) useDeepCompareEffect(() => { const id = nanoid() socketEmitPromise(socket, 'preview:button-reference:subscribe', [id, location, options]) @@ -25,7 +31,7 @@ export function OptionButtonPreview({ location, options }) { setImage(null) }) - const updateImage = (newImage) => { + const updateImage = (newImage: string) => { setImage(newImage) } @@ -41,5 +47,5 @@ export function OptionButtonPreview({ location, options }) { // TODO - is this too reactive watching all the options? }, [location, options]) - return + return // TODO - noPad? } diff --git a/webui/src/Controls/OptionsInputField.jsx b/webui/src/Controls/OptionsInputField.tsx similarity index 76% rename from webui/src/Controls/OptionsInputField.jsx rename to webui/src/Controls/OptionsInputField.tsx index cf6264b243..c8b85a3eb3 100644 --- a/webui/src/Controls/OptionsInputField.jsx +++ b/webui/src/Controls/OptionsInputField.tsx @@ -6,10 +6,24 @@ import { DropdownInputField, NumberInputField, TextInputField, -} from '../Components' -import { InternalCustomVariableDropdown, InternalInstanceField } from './InternalInstanceFields' +} from '../Components/index.js' +import { InternalCustomVariableDropdown, InternalInstanceField } from './InternalInstanceFields.jsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, faQuestionCircle } from '@fortawesome/free-solid-svg-icons' +import { InternalActionInputField, InternalFeedbackInputField } from '@companion/shared/Model/Options.js' +import classNames from 'classnames' + +interface OptionsInputFieldProps { + connectionId: string + isOnControl: boolean + isAction: boolean + actionId: string + option: InternalActionInputField | InternalFeedbackInputField + value: any + setValue: (actionId: string, key: string, value: any) => void + visibility: boolean + readonly?: boolean +} export function OptionsInputField({ connectionId, @@ -21,15 +35,15 @@ export function OptionsInputField({ setValue, visibility, readonly, -}) { - const setValue2 = useCallback((val) => setValue(actionId, option.id, val), [actionId, option.id, setValue]) +}: OptionsInputFieldProps) { + const setValue2 = useCallback((val: any) => setValue(actionId, option.id, val), [actionId, option.id, setValue]) if (!option) { return

Bad option

} - let control = undefined - let features = {} + let control: JSX.Element | string | undefined = undefined + let features: Record = {} switch (option.type) { case 'textinput': { control = ( @@ -124,7 +138,7 @@ export function OptionsInputField({ case 'custom-variable': { if (isAction) { control = ( - + ) } break @@ -132,7 +146,7 @@ export function OptionsInputField({ default: // The 'internal instance' is allowed to use some special input fields, to minimise when it reacts to changes elsewhere in the system if (connectionId === 'internal') { - control = InternalInstanceField(option, isOnControl, readonly, value, setValue2) + control = InternalInstanceField(option, isOnControl, !!readonly, value, setValue2) ?? undefined } // Use default below break @@ -142,12 +156,12 @@ export function OptionsInputField({ control = Unknown type "{option.type}" } - const featureIcons = [] + const featureIcons: JSX.Element[] = [] if (features.variables) featureIcons.push() return ( - + {option.label} {featureIcons.length ? {featureIcons} : ''} diff --git a/webui/src/Emulator/Emulator.jsx b/webui/src/Emulator/Emulator.tsx similarity index 66% rename from webui/src/Emulator/Emulator.jsx rename to webui/src/Emulator/Emulator.tsx index e7ef6b2870..bde484b4fe 100644 --- a/webui/src/Emulator/Emulator.jsx +++ b/webui/src/Emulator/Emulator.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, useContext } from 'react' +import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react' import { LoadingRetryOrError, applyPatchOrReplaceObject, @@ -7,24 +7,28 @@ import { socketEmitPromise, useMountEffect, PreventDefaultHandler, -} from '../util' +} from '../util.js' import { CButton, CCol, CForm, CRow } from '@coreui/react' import { nanoid } from 'nanoid' import { useParams } from 'react-router-dom' -import { dsanMastercueKeymap, keyboardKeymap, logitecKeymap } from './Keymaps' -import { ButtonPreview } from '../Components/ButtonPreview' +import { dsanMastercueKeymap, keyboardKeymap, logitecKeymap } from './Keymaps.js' +import { ButtonPreview } from '../Components/ButtonPreview.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCancel, faExpand } from '@fortawesome/free-solid-svg-icons' +import { ControlLocation, EmulatorConfig, EmulatorImage } from '@companion/shared/Model/Common.js' +import { Operation as JsonPatchOperation } from 'fast-json-patch' + +type EmulatorImageCache = Record | undefined> export function Emulator() { const socket = useContext(SocketContext) - const [config, setConfig] = useState(null) - const [loadError, setLoadError] = useState(null) + const [config, setConfig] = useState(null) + const [loadError, setLoadError] = useState(null) const { id: emulatorId } = useParams() - const [imageCache, setImageCache] = useState({}) + const [imageCache, setImageCache] = useState({}) useEffect(() => { // Clear the images on id change setImageCache({}) @@ -40,13 +44,13 @@ export function Emulator() { .then((config) => { setConfig(config) }) - .catch((e) => { + .catch((e: any) => { console.error('Emulator error', e) setLoadError(`Failed: ${e}`) }) - const updateConfig = (patch) => { - setConfig((oldConfig) => applyPatchOrReplaceObject(oldConfig, patch)) + const updateConfig = (patch: JsonPatchOperation[]) => { + setConfig((oldConfig) => oldConfig && applyPatchOrReplaceObject(oldConfig, patch)) } socket.on('emulator:config', updateConfig) @@ -65,15 +69,15 @@ export function Emulator() { }, [config?.emulator_control_enable]) useEffect(() => { - const updateImages = (newImages) => { + const updateImages = (newImages: EmulatorImage[]) => { console.log('new images', newImages) setImageCache((old) => { if (Array.isArray(newImages)) { const res = { ...old } for (const change of newImages) { - res[change.y] = { ...res[change.y] } - res[change.y][change.x] = change.buffer + const row = (res[change.y] = { ...res[change.y] }) + row[change.x] = change.buffer } return res @@ -100,24 +104,26 @@ export function Emulator() { } }, [socket]) - const [keyDown, setKeyDown] = useState(null) + const [keyDown, setKeyDown] = useState(null) // Register key handlers useEffect(() => { - const onKeyDown = (e) => { + const onKeyDown = (e: KeyboardEvent) => { if (keymap[e.keyCode] !== undefined) { const xy = keymap[e.keyCode] - socketEmitPromise(socket, 'emulator:press', [emulatorId, ...xy]).catch((e) => { - console.error('press failed', e) - }) - console.log('emulator:press', emulatorId, xy) + if (xy) { + socketEmitPromise(socket, 'emulator:press', [emulatorId, ...xy]).catch((e: any) => { + console.error('press failed', e) + }) + console.log('emulator:press', emulatorId, xy) + } } } - const onKeyUp = (e) => { + const onKeyUp = (e: KeyboardEvent) => { const xy = keymap[e.keyCode] if (xy) { - socketEmitPromise(socket, 'emulator:release', [emulatorId, ...xy]).catch((e) => { + socketEmitPromise(socket, 'emulator:release', [emulatorId, ...xy]).catch((e: any) => { console.error('release failed', e) }) console.log('emulator:release', emulatorId, xy) @@ -135,23 +141,23 @@ export function Emulator() { useEffect(() => { // handle changes to keyDown, as it isnt safe to do inside setState - if (keyDown) { - socketEmitPromise(socket, 'emulator:press', [emulatorId, keyDown.column, keyDown.row]).catch((e) => { - console.error('press failed', e) - }) - console.log('emulator:press', emulatorId, keyDown) + if (!keyDown) return - return () => { - socketEmitPromise(socket, 'emulator:release', [emulatorId, keyDown.column, keyDown.row]).catch((e) => { - console.error('release failed', e) - }) - console.log('emulator:release', emulatorId, keyDown) - } + socketEmitPromise(socket, 'emulator:press', [emulatorId, keyDown.column, keyDown.row]).catch((e: any) => { + console.error('press failed', e) + }) + console.log('emulator:press', emulatorId, keyDown) + + return () => { + socketEmitPromise(socket, 'emulator:release', [emulatorId, keyDown.column, keyDown.row]).catch((e: any) => { + console.error('release failed', e) + }) + console.log('emulator:release', emulatorId, keyDown) } }, [socket, keyDown, emulatorId]) useEffect(() => { - const onMouseUp = (e) => { + const onMouseUp = (e: MouseEvent) => { e.preventDefault() setKeyDown(null) } @@ -186,7 +192,11 @@ export function Emulator() { ) } -function ConfigurePanel({ config }) { +interface ConfigurePanelProps { + config: EmulatorConfig +} + +function ConfigurePanel({ config }: ConfigurePanelProps): JSX.Element | null { const [show, setShow] = useState(true) const [fullscreen, setFullscreen] = useState(document.fullscreenElement !== null) @@ -226,14 +236,19 @@ function ConfigurePanel({ config }) {
- ) : ( - '' - ) + ) : null } -function EmulatorButtons({ imageCache, setKeyDown, columns, rows }) { +interface EmulatorButtonsProps { + imageCache: EmulatorImageCache + setKeyDown: (location: ControlLocation | null) => void + columns: number + rows: number +} + +function EmulatorButtons({ imageCache, setKeyDown, columns, rows }: EmulatorButtonsProps) { const buttonClick = useCallback( - (location, pressed) => { + (location: ControlLocation, pressed: boolean) => { if (pressed) { setKeyDown(location) } else { @@ -257,16 +272,7 @@ function EmulatorButtons({ imageCache, setKeyDown, columns, rows }) { for (let y = 0; y < rows; y++) { for (let x = 0; x < columns; x++) { buttonElms.push( - + ) } } @@ -282,7 +288,14 @@ function EmulatorButtons({ imageCache, setKeyDown, columns, rows }) { ) } -function ButtonPreview2({ pageNumber, column, row, ...props }) { - const location = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) - return +interface ButtonPreview2Props { + column: number + row: number + + preview: string | undefined | null | false + onClick: (location: ControlLocation, pressed: boolean) => void +} +function ButtonPreview2({ column, row, ...props }: ButtonPreview2Props) { + const location = useMemo(() => ({ pageNumber: 0, column, row }), [column, row]) + return } diff --git a/webui/src/Emulator/Keymaps.js b/webui/src/Emulator/Keymaps.ts similarity index 76% rename from webui/src/Emulator/Keymaps.js rename to webui/src/Emulator/Keymaps.ts index e94ded4e83..fdb54394e3 100644 --- a/webui/src/Emulator/Keymaps.js +++ b/webui/src/Emulator/Keymaps.ts @@ -1,5 +1,7 @@ +export type KeyMap = Record + // Added last row for logitec controllers (PageUp, PageDown, F5, Escape, .) -export const keyboardKeymap = { +export const keyboardKeymap: KeyMap = { 49: [0, 0], 50: [1, 0], 51: [2, 0], @@ -34,7 +36,7 @@ export const keyboardKeymap = { 188: [7, 3], } -export const logitecKeymap = { +export const logitecKeymap: KeyMap = { 33: [1, 0], 34: [2, 0], 190: [3, 0], @@ -42,7 +44,7 @@ export const logitecKeymap = { 27: [2, 1], } -export const dsanMastercueKeymap = { +export const dsanMastercueKeymap: KeyMap = { 37: [1, 0], 39: [2, 0], 66: [3, 0], diff --git a/webui/src/Emulator/List.jsx b/webui/src/Emulator/List.tsx similarity index 75% rename from webui/src/Emulator/List.jsx rename to webui/src/Emulator/List.tsx index c6295c74d7..4a1ac93f86 100644 --- a/webui/src/Emulator/List.jsx +++ b/webui/src/Emulator/List.tsx @@ -1,16 +1,17 @@ -import { useCallback, useEffect, useMemo, useState, useContext } from 'react' -import { LoadingRetryOrError, SocketContext, socketEmitPromise } from '../util' +import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react' +import { LoadingRetryOrError, SocketContext, socketEmitPromise } from '../util.js' import { CAlert, CCol, CContainer, CRow, CWidgetSimple } from '@coreui/react' import { nanoid } from 'nanoid' import { useNavigate } from 'react-router-dom' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { cloneDeep } from 'lodash-es' +import type { ClientDevicesListItem, ClientSurfaceItem } from '@companion/shared/Model/Surfaces.js' export function EmulatorList() { const socket = useContext(SocketContext) - const [surfaceGroups, setSurfaceGroups] = useState(null) - const [loadError, setLoadError] = useState(null) + const [surfaceGroups, setSurfaceGroups] = useState | null>(null) + const [loadError, setLoadError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doRetryLoad = useCallback(() => setReloadToken(nanoid()), []) @@ -28,22 +29,22 @@ export function EmulatorList() { setLoadError('Failed to load surfaces') }) - const patchSurfaces = (patch) => { + const patchSurfaces = (patch: JsonPatchOperation[]) => { setSurfaceGroups((oldSurfaces) => { - return jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument + return oldSurfaces && jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument }) } socket.on('surfaces:patch', patchSurfaces) return () => { - socketEmitPromise(socket, 'surfaces:unsubscribe', []).catch((e) => { + socketEmitPromise(socket, 'surfaces:unsubscribe', []).catch((e: any) => { console.error('Failed to unsubscribe from surfaces', e) }) } }, [socket, reloadToken]) const emulators = useMemo(() => { - const emulators = [] + const emulators: ClientSurfaceItem[] = [] for (const group of Object.values(surfaceGroups ?? {})) { if (!group) continue @@ -61,7 +62,7 @@ export function EmulatorList() { return (
- + {surfaceGroups && ( @@ -94,7 +95,10 @@ export function EmulatorList() { ) } -function EmulatorCard({ surface }) { +interface EmulatorCardProps { + surface: ClientSurfaceItem +} +function EmulatorCard({ surface }: EmulatorCardProps) { const navigate = useNavigate() const click = useCallback(() => { navigate(`/emulator/${surface.id.substring(9)}`) diff --git a/webui/src/GettingStarted.jsx b/webui/src/GettingStarted.tsx similarity index 66% rename from webui/src/GettingStarted.jsx rename to webui/src/GettingStarted.tsx index 8426f69776..8292e5cafa 100644 --- a/webui/src/GettingStarted.jsx +++ b/webui/src/GettingStarted.tsx @@ -1,9 +1,15 @@ -import { Fragment, useRef, useState, useEffect } from 'react' +import React, { Fragment, useRef, useState, useEffect } from 'react' import { useHash } from 'react-use' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { useIntersectionObserver } from 'usehooks-ts' +interface DocsSection { + label: string + file: string + children?: DocsSection[] +} + const style = { header: { height: 49, @@ -15,9 +21,17 @@ const style = { zIndex: 300, position: 'relative', display: 'flex', - }, - headerText: { lineHeight: '1.1em', marginTop: 4, marginLeft: 5 }, - menuWrapper: { backgroundColor: 'white', display: 'flex', zIndex: 1 }, + } satisfies React.CSSProperties, + headerText: { + lineHeight: '1.1em', + marginTop: 4, + marginLeft: 5, + } satisfies React.CSSProperties, + menuWrapper: { + backgroundColor: 'white', + display: 'flex', + zIndex: 1, + } satisfies React.CSSProperties, menuStructure: { width: '20vw', minWidth: 250, @@ -25,7 +39,7 @@ const style = { overflow: 'scroll', zIndex: 200, boxShadow: '-15px 2px 31px 22px rgba(100,100,100,0.1)', - }, + } satisfies React.CSSProperties, contentGithubLink: { backgroundColor: '#f0f0f0', display: 'inline-block', @@ -35,7 +49,7 @@ const style = { padding: '2px 5px', clear: 'both', float: 'right', - }, + } satisfies React.CSSProperties, contentWrapper: { width: '80vw', maxWidth: 'calc(100vw - 250px)', @@ -45,27 +59,37 @@ const style = { zIndex: 100, padding: 20, paddingLeft: 40, - }, + } satisfies React.CSSProperties, contentWrapper2: { maxWidth: 1200, - }, - menuChildren: { marginLeft: 3, borderLeft: '1px dotted gray', paddingLeft: 20, marginBottom: 10 }, - imgLink: { width: 12, opacity: 0.3, marginTop: -2, marginLeft: 4 }, + } satisfies React.CSSProperties, + menuChildren: { + marginLeft: 3, + borderLeft: '1px dotted gray', + paddingLeft: 20, + marginBottom: 10, + } satisfies React.CSSProperties, + imgLink: { + width: 12, + opacity: 0.3, + marginTop: -2, + marginLeft: 4, + } satisfies React.CSSProperties, } export function GettingStarted() { const [hash] = useHash() - const contentWrapperRef = useRef(null) + const contentWrapperRef = useRef(null) const [structure, setStructure] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) - const [visibleFiles, setVisibleFiles] = useState([]) + const [visibleFiles, setVisibleFiles] = useState([]) useEffect(() => { setTimeout(() => { if (contentWrapperRef.current) { // scroll to hash - const el = contentWrapperRef.current.querySelector(`[anchor="${hash}"]`) + const el = contentWrapperRef.current.querySelector(`[data-anchor="${hash}"]`) if (el) { el.scrollIntoView({ behavior: 'smooth' }) } @@ -77,24 +101,20 @@ export function GettingStarted() { useEffect(() => { const fetchData = async () => { try { - try { - const response = await fetch(`/docs/structure.json`) - const structure = await response.json() - setStructure(structure) - } catch (e) { - setError(e) - } - - setLoading(false) - } catch (err) { - setError(err) + const response = await fetch(`/docs/structure.json`) + const structure = await response.json() + setStructure(structure) + } catch (e: any) { + setError(e) } + + setLoading(false) } fetchData() }, []) - const iterateMenu = (s, path, depth) => { + const iterateMenu = (s: DocsSection[], path: string[], depth: number) => { return ( {loading ? ( @@ -125,18 +145,12 @@ export function GettingStarted() { ) } - const iterateContent = (s, path, depth) => { + const iterateContent = (s: DocsSection[], path: string[], depth: number) => { return ( {s.map((subsect) => ( - + {subsect.children &&
{iterateContent(subsect.children, [...path, subsect.label], depth + 1)}
}
))} @@ -166,7 +180,7 @@ export function GettingStarted() {
-
+
{iterateContent(structure, [], 0)}
@@ -177,25 +191,28 @@ export function GettingStarted() { ) } -function RenderSubsection({ subsect, path, depth, setVisibleFiles, visibleFiles }) { +interface RenderSubsectionProps { + subsect: DocsSection + setVisibleFiles: (visibleFiles: string[]) => void + visibleFiles: string[] +} +function RenderSubsection({ subsect, setVisibleFiles, visibleFiles }: RenderSubsectionProps) { return ( {subsect.file && (
{ - let updatedVisible - if (visible) { - updatedVisible = [...visibleFiles, subsect.file] - } else { - updatedVisible = visibleFiles.filter((f) => f !== subsect.file) - } + const updatedVisible = visible + ? [...visibleFiles, subsect.file].filter((f): f is string => !!f) + : visibleFiles.filter((f) => f !== subsect.file) + if (JSON.stringify(visible) !== JSON.stringify(updatedVisible)) { setVisibleFiles(updatedVisible) } }} > -

+

{subsect.label}

('') const [loading, setLoading] = useState(true) // strip filename @@ -239,7 +259,7 @@ function LoadContent({ file }) { 'loading' ) : ( { + transformImageUri={(src, _alt, _title) => { return `/docs/${baseUrl}${src}` }} children={content} @@ -250,12 +270,15 @@ function LoadContent({ file }) { ) } -function OnScreenReporter({ children, onChange }) { - const ref = useRef() +interface OnScreenReporterProps { + onChange: (isOnScreen: boolean) => void +} +function OnScreenReporter({ children, onChange }: React.PropsWithChildren) { + const ref = useRef(null) const entry = useIntersectionObserver(ref, {}) - const isOnScreen = entry?.isIntersecting + const isOnScreen = entry?.isIntersecting ?? false - const [visible, setVisible] = useState(null) + const [visible, setVisible] = useState(null) useEffect(() => { if (isOnScreen !== visible) { diff --git a/webui/src/Helpers/CollapseHelper.jsx b/webui/src/Helpers/CollapseHelper.tsx similarity index 73% rename from webui/src/Helpers/CollapseHelper.jsx rename to webui/src/Helpers/CollapseHelper.tsx index bc61099eee..a0e0e1143e 100644 --- a/webui/src/Helpers/CollapseHelper.jsx +++ b/webui/src/Helpers/CollapseHelper.tsx @@ -1,9 +1,23 @@ import { useCallback, useEffect, useState } from 'react' -export function usePanelCollapseHelper(storageId, panelIds) { +interface PanelCollapseHelperResult { + setAllCollapsed: () => void + setAllExpanded: () => void + canExpandAll: boolean + canCollapseAll: boolean + setPanelCollapsed: (panelId: string, collapsed: boolean) => void + isPanelCollapsed: (panelId: string) => boolean +} + +interface CollapsedState { + defaultCollapsed: boolean + ids: Record +} + +export function usePanelCollapseHelper(storageId: string, panelIds: string[]): PanelCollapseHelperResult { const collapseStorageId = `companion_ui_collapsed_${storageId}` - const [collapsed, setCollapsed] = useState({}) + const [collapsed, setCollapsed] = useState({ defaultCollapsed: false, ids: {} }) useEffect(() => { // Reload from storage whenever the storage key changes const oldState = window.localStorage.getItem(collapseStorageId) @@ -20,14 +34,14 @@ export function usePanelCollapseHelper(storageId, panelIds) { const setPanelCollapsed = useCallback( (panelId, collapsed) => { setCollapsed((oldState) => { - const newState = { + const newState: CollapsedState = { ...oldState, ids: {}, } // preserve only the panels which exist for (const id of panelIds) { - newState.ids[id] = oldState.ids[id] + newState.ids[id] = oldState.ids?.[id] } // set the new one @@ -41,7 +55,7 @@ export function usePanelCollapseHelper(storageId, panelIds) { ) const setAllCollapsed = useCallback(() => { setCollapsed((oldState) => { - const newState = { + const newState: CollapsedState = { ...oldState, defaultCollapsed: true, ids: {}, @@ -58,7 +72,7 @@ export function usePanelCollapseHelper(storageId, panelIds) { }, [collapseStorageId, panelIds]) const setAllExpanded = useCallback(() => { setCollapsed((oldState) => { - const newState = { + const newState: CollapsedState = { ...oldState, defaultCollapsed: false, ids: {}, @@ -75,8 +89,8 @@ export function usePanelCollapseHelper(storageId, panelIds) { }, [collapseStorageId, panelIds]) const isPanelCollapsed = useCallback( - (panelId) => { - return collapsed?.ids?.[panelId] ?? collapsed?.defaultCollapsed + (panelId: string) => { + return collapsed?.ids?.[panelId] ?? collapsed?.defaultCollapsed ?? false }, [collapsed] ) diff --git a/webui/src/Helpers/Window.jsx b/webui/src/Helpers/Window.jsx deleted file mode 100644 index 093b204294..0000000000 --- a/webui/src/Helpers/Window.jsx +++ /dev/null @@ -1,14 +0,0 @@ -export const windowLinkOpen = ({ href, sameWindow }) => { - window.open(href, !sameWindow ? '_blank' : '', 'noreferrer') -} -export function WindowLinkOpen({ children, href, sameWindow = false, title }) { - return ( -
windowLinkOpen({ href, sameWindow })} - style={{ display: 'inline-block', cursor: 'pointer' }} - title={title} - > - {children} -
- ) -} diff --git a/webui/src/Helpers/Window.tsx b/webui/src/Helpers/Window.tsx new file mode 100644 index 0000000000..fa3790a3a9 --- /dev/null +++ b/webui/src/Helpers/Window.tsx @@ -0,0 +1,28 @@ +import React from 'react' + +export const windowLinkOpen = ({ href, sameWindow }: { href: string; sameWindow?: boolean; title?: string }) => { + window.open(href, !sameWindow ? '_blank' : '', 'noreferrer') +} + +interface WindowLinkOpenProps { + href: string + sameWindow?: boolean + title?: string +} + +export function WindowLinkOpen({ + children, + href, + sameWindow = false, + title, +}: React.PropsWithChildren) { + return ( +
windowLinkOpen({ href, sameWindow })} + style={{ display: 'inline-block', cursor: 'pointer' }} + title={title} + > + {children} +
+ ) +} diff --git a/webui/src/Hooks/useElementInnerSize.js b/webui/src/Hooks/useElementInnerSize.ts similarity index 71% rename from webui/src/Hooks/useElementInnerSize.js rename to webui/src/Hooks/useElementInnerSize.ts index 8be53c3784..9dde89b1a2 100644 --- a/webui/src/Hooks/useElementInnerSize.js +++ b/webui/src/Hooks/useElementInnerSize.ts @@ -1,8 +1,11 @@ import { useCallback, useState } from 'react' import { useEventListener, useIsomorphicLayoutEffect } from 'usehooks-ts' -export default function useElementclientSize() { - const [ref, setRef] = useState(null) +export default function useElementclientSize(): [ + (elm: TElement | null) => void, + { width: number; height: number }, +] { + const [ref, setRef] = useState(null) const [size, setSize] = useState({ width: 0, height: 0, diff --git a/webui/src/Hooks/useHasBeenRendered.js b/webui/src/Hooks/useHasBeenRendered.ts similarity index 77% rename from webui/src/Hooks/useHasBeenRendered.js rename to webui/src/Hooks/useHasBeenRendered.ts index da90e2294a..ab6d6015a0 100644 --- a/webui/src/Hooks/useHasBeenRendered.js +++ b/webui/src/Hooks/useHasBeenRendered.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useInView } from 'react-intersection-observer' -export function useHasBeenRendered() { +export function useHasBeenRendered(): [hasBeenInView: boolean, ref: (node: Element | null | undefined) => void] { // Track whether this tab has been rendered, to allow lazy rendering of the grid component const { ref, inView } = useInView() const [hasBeenInView, setHasBeenInView] = useState(false) diff --git a/webui/src/Hooks/useOptionsAndIsVisible.ts b/webui/src/Hooks/useOptionsAndIsVisible.ts new file mode 100644 index 0000000000..456168770c --- /dev/null +++ b/webui/src/Hooks/useOptionsAndIsVisible.ts @@ -0,0 +1,60 @@ +import type { ExtendedInputField, InternalInputField, IsVisibleFunction } from '@companion/shared/Model/Options' +import { useMemo, useEffect, useState } from 'react' +import { sandbox } from '../util' +import { CompanionOptionValues } from '@companion-module/base' + +interface IsVisibleFunctionEntry { + fn: IsVisibleFunction + data: any +} + +export function useOptionsAndIsVisible( + itemSpec: { options: Array } | undefined, + item: { options: CompanionOptionValues } | undefined +): [options: Array, optionVisibility: Record] { + const [optionVisibility, setOptionVisibility] = useState>({}) + + const [options, isVisibleFns] = useMemo(() => { + const options = itemSpec?.options ?? [] + const isVisibleFns: Record = {} + + for (const option of options) { + try { + if (typeof option.isVisibleFn === 'string') { + isVisibleFns[option.id] = { + fn: sandbox(option.isVisibleFn), + data: option.isVisibleData, + } + } + } catch (e) { + console.error('Failed to process isVisibleFn', e) + } + } + + return [options, isVisibleFns] + }, [itemSpec]) + + useEffect(() => { + const visibility: Record = {} + + if (item) { + for (const [id, entry] of Object.entries(isVisibleFns)) { + try { + if (entry && typeof entry.fn === 'function') { + visibility[id] = entry.fn(item.options, entry.data) + } + } catch (e) { + console.error('Failed to check visibility', e) + } + } + } + + setOptionVisibility(visibility) + + return () => { + setOptionVisibility({}) + } + }, [isVisibleFns, item]) + + return [options, optionVisibility] +} diff --git a/webui/src/Hooks/usePagePicker.js b/webui/src/Hooks/usePagePicker.ts similarity index 74% rename from webui/src/Hooks/usePagePicker.js rename to webui/src/Hooks/usePagePicker.ts index fcfd999922..ea5ba01f4a 100644 --- a/webui/src/Hooks/usePagePicker.js +++ b/webui/src/Hooks/usePagePicker.ts @@ -1,9 +1,10 @@ +import { PageModel } from '@companion/shared/Model/PageModel' import { useCallback, useEffect, useRef, useState } from 'react' -export function usePagePicker(pagesObj, initialPage) { +export function usePagePicker(pagesObj: Record, initialPage: number) { const [pageNumber, setPageNumber] = useState(Number(initialPage)) - const pagesRef = useRef() + const pagesRef = useRef>() useEffect(() => { // Avoid binding into callbacks pagesRef.current = pagesObj @@ -19,7 +20,7 @@ export function usePagePicker(pagesObj, initialPage) { if (newIndex < 0) newIndex += pageNumbers.length if (newIndex >= pageNumbers.length) newIndex -= pageNumbers.length - newPage = pageNumbers[newIndex] + newPage = Number(pageNumbers[newIndex]) } return newPage ?? pageNumber diff --git a/webui/src/Hooks/usePagesInfoSubscription.js b/webui/src/Hooks/usePagesInfoSubscription.ts similarity index 68% rename from webui/src/Hooks/usePagesInfoSubscription.js rename to webui/src/Hooks/usePagesInfoSubscription.ts index d8b39459e9..1925afb247 100644 --- a/webui/src/Hooks/usePagesInfoSubscription.js +++ b/webui/src/Hooks/usePagesInfoSubscription.ts @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react' import { socketEmitPromise } from '../util' +import { Socket } from 'socket.io-client' +import type { PageModel } from '@companion/shared/Model/PageModel' -export function usePagesInfoSubscription(socket, setLoadError, retryToken) { - const [pages, setPages] = useState(null) +export function usePagesInfoSubscription( + socket: Socket, + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string +) { + const [pages, setPages] = useState | null>(null) useEffect(() => { setLoadError?.(null) @@ -19,7 +25,7 @@ export function usePagesInfoSubscription(socket, setLoadError, retryToken) { setPages(null) }) - const updatePageInfo = (page, info) => { + const updatePageInfo = (page: number, info: PageModel) => { setPages((oldPages) => { if (oldPages) { return { diff --git a/webui/src/Hooks/useScrollPosition.js b/webui/src/Hooks/useScrollPosition.js deleted file mode 100644 index e3bcfdd80c..0000000000 --- a/webui/src/Hooks/useScrollPosition.js +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' - -export default function useScrollPosition() { - const [scrollPosition, setScrollPosition] = useState([0, 0]) - - const [scrollerRef, setRef] = useState(null) - - useEffect(() => { - if (scrollerRef) { - setScrollPosition([scrollerRef.scrollLeft, scrollerRef.scrollTop]) - - const onScroll = () => setScrollPosition([scrollerRef.scrollLeft, scrollerRef.scrollTop]) - - scrollerRef.addEventListener('scroll', onScroll) - - return () => { - scrollerRef.removeEventListener('scroll', onScroll) - } - } - }, [scrollerRef]) - - return useMemo( - () => ({ - scrollX: scrollPosition[0], - scrollY: scrollPosition[1], - setRef, - }), - [scrollPosition, setRef] - ) -} diff --git a/webui/src/Hooks/useScrollPosition.ts b/webui/src/Hooks/useScrollPosition.ts new file mode 100644 index 0000000000..aa37e106b1 --- /dev/null +++ b/webui/src/Hooks/useScrollPosition.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo, useState } from 'react' + +interface useScrollPositionResult { + scrollX: number + scrollY: number + setRef: (ref: TElement) => void +} + +export default function useScrollPosition(): useScrollPositionResult { + const [scrollPosition, setScrollPosition] = useState([0, 0]) + + const [scrollerRef, setRef] = useState(null) + + useEffect(() => { + if (!scrollerRef) return + + setScrollPosition([scrollerRef.scrollLeft, scrollerRef.scrollTop]) + + const onScroll = () => setScrollPosition([scrollerRef.scrollLeft, scrollerRef.scrollTop]) + + scrollerRef.addEventListener('scroll', onScroll) + + return () => { + scrollerRef.removeEventListener('scroll', onScroll) + } + }, [scrollerRef]) + + return useMemo( + () => ({ + scrollX: scrollPosition[0], + scrollY: scrollPosition[1], + setRef, + }), + [scrollPosition, setRef] + ) +} diff --git a/webui/src/Hooks/useSharedRenderCache.js b/webui/src/Hooks/useSharedRenderCache.ts similarity index 75% rename from webui/src/Hooks/useSharedRenderCache.js rename to webui/src/Hooks/useSharedRenderCache.ts index 59f79f937d..68bed9737a 100644 --- a/webui/src/Hooks/useSharedRenderCache.js +++ b/webui/src/Hooks/useSharedRenderCache.ts @@ -1,22 +1,27 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { SocketContext, socketEmitPromise } from '../util' import { nanoid } from 'nanoid' +import { ControlLocation } from '@companion/shared/Model/Common' + +interface ImageState { + image: string | null + isUsed: boolean +} /** * Load and retrieve a page from the shared button render cache - * @param {string} sessionId Unique id of this accessor - * @param {number | undefined} page Page number to load and retrieve - * @param {boolean | undefined} disable Disable loading of this page + * @param location Location of the control to load + * @param disable Disable loading of this page * @returns */ -export function useButtonRenderCache(location, disable = false) { +export function useButtonRenderCache(location: ControlLocation, disable = false) { const socket = useContext(SocketContext) const subId = useMemo(() => nanoid(), []) // TODO - should these be managed a bit more centrally, and batched? It is likely that lots of subscribe/unsubscribe calls will happen at once (changing page/scrolling) - const [imageState, setImageState] = useState({ image: null, isUsed: false }) + const [imageState, setImageState] = useState({ image: null, isUsed: false }) useEffect(() => { if (disable) return @@ -37,7 +42,7 @@ export function useButtonRenderCache(location, disable = false) { console.error(e) }) - const changeHandler = (renderLocation, image, isUsed) => { + const changeHandler = (renderLocation: ControlLocation, image: string | null, isUsed: boolean) => { if (terminated) return if ( diff --git a/webui/src/Hooks/useUserConfigSubscription.js b/webui/src/Hooks/useUserConfigSubscription.ts similarity index 58% rename from webui/src/Hooks/useUserConfigSubscription.js rename to webui/src/Hooks/useUserConfigSubscription.ts index 14d0fa928a..21a1e17070 100644 --- a/webui/src/Hooks/useUserConfigSubscription.js +++ b/webui/src/Hooks/useUserConfigSubscription.ts @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react' import { socketEmitPromise } from '../util' +import { Socket } from 'socket.io-client' +import { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function useUserConfigSubscription(socket, setLoadError, retryToken) { - const [userConfig, setUserConfig] = useState(null) +export function useUserConfigSubscription( + socket: Socket, + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string +) { + const [userConfig, setUserConfig] = useState(null) useEffect(() => { setLoadError?.(null) @@ -18,11 +24,15 @@ export function useUserConfigSubscription(socket, setLoadError, retryToken) { setLoadError?.(`Failed to load user config`) }) - const updateUserConfigValue = (key, value) => { - setUserConfig((oldState) => ({ - ...oldState, - [key]: value, - })) + const updateUserConfigValue = (key: keyof UserConfigModel, value: any) => { + setUserConfig((oldState) => + oldState + ? { + ...oldState, + [key]: value, + } + : null + ) } socket.on('set_userconfig_key', updateUserConfigValue) diff --git a/webui/src/ImportExport/Export.jsx b/webui/src/ImportExport/Export.tsx similarity index 50% rename from webui/src/ImportExport/Export.jsx rename to webui/src/ImportExport/Export.tsx index 1bb3fa0c65..120da92834 100644 --- a/webui/src/ImportExport/Export.jsx +++ b/webui/src/ImportExport/Export.tsx @@ -1,101 +1,122 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import React, { FormEvent, forwardRef, useCallback, useImperativeHandle, useState } from 'react' import { CButton, CForm, CInputCheckbox, CLabel, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' import { PreventDefaultHandler } from '../util' import { ExportFormatDefault, SelectExportFormat } from './ExportFormat' import { MenuPortalContext } from '../Components/DropdownInputField' +import { ClientExportSelection } from '@companion/shared/Model/ImportExport' -export const ExportWizardModal = forwardRef(function WizardModal(_props, ref) { - const [show, setShow] = useState(false) - const [config, setConfig] = useState({}) +interface ExportWizardModalProps {} +export interface ExportWizardModalRef { + show(): void +} + +export const ExportWizardModal = forwardRef( + function ExportWizardModal(_props, ref) { + const [show, setShow] = useState(false) + const [config, setConfig] = useState({ + connections: true, + buttons: true, + surfaces: true, + triggers: true, + customVariables: true, + // userconfig: true, + format: ExportFormatDefault, + }) - const doClose = useCallback(() => { - setShow(false) - }, []) + const doClose = useCallback(() => { + setShow(false) + }, []) - const doSave = useCallback( - (e) => { - e.preventDefault() + const doSave = useCallback( + (e: FormEvent) => { + e.preventDefault() - const params = new URLSearchParams() - for (const [key, value] of Object.entries(config)) { - if (typeof value === 'boolean') { - params.set(key, value ? '1' : '0') - } else { - params.set(key, value + '') + const params = new URLSearchParams() + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'boolean') { + params.set(key, value ? '1' : '0') + } else { + params.set(key, value + '') + } } - } - - const link = document.createElement('a') - link.setAttribute('download', 'export.companionconfig') - link.href = `/int/export/custom?${params}` - document.body.appendChild(link) - link.click() - link.remove() - - doClose() - }, - [config, doClose] - ) - const setValue = (key, value) => { - setConfig((oldState) => ({ - ...oldState, - [key]: value, - })) - } + const link = document.createElement('a') + link.setAttribute('download', 'export.companionconfig') + link.href = `/int/export/custom?${params}` + document.body.appendChild(link) + link.click() + link.remove() - useImperativeHandle( - ref, - () => ({ - show() { - setConfig({ - connections: true, - buttons: true, - surfaces: true, - triggers: true, - customVariables: true, - // userconfig: true, - format: ExportFormatDefault, - }) - - setShow(true) + doClose() }, - }), - [] - ) + [config, doClose] + ) - const canExport = Object.values(config).find((v) => !!v) + const setValue = (key: keyof ClientExportSelection, value: any) => { + setConfig((oldState) => ({ + ...oldState, + [key]: value, + })) + } - const [modalRef, setModalRef] = useState(null) + useImperativeHandle( + ref, + () => ({ + show() { + setConfig({ + connections: true, + buttons: true, + surfaces: true, + triggers: true, + customVariables: true, + // userconfig: true, + format: ExportFormatDefault, + }) - return ( - - - - -

- logo - Export Configuration -

-
- - - - - - Close - - - Download - - -
-
-
- ) -}) + setShow(true) + }, + }), + [] + ) + + const canExport = Object.values(config).find((v) => !!v) + + const [modalRef, setModalRef] = useState(null) + + return ( + + + + +

+ logo + Export Configuration +

+
+ + + + + + Close + + + Download + + +
+
+
+ ) + } +) + +interface ExportOptionsStepProps { + config: ClientExportSelection + setValue: (key: keyof ClientExportSelection, value: any) => void +} -function ExportOptionsStep({ config, setValue }) { +function ExportOptionsStep({ config, setValue }: ExportOptionsStepProps) { return (
Export Options
diff --git a/webui/src/ImportExport/ExportFormat.jsx b/webui/src/ImportExport/ExportFormat.jsx deleted file mode 100644 index b3df04e8b9..0000000000 --- a/webui/src/ImportExport/ExportFormat.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { memo } from 'react' -import { DropdownInputField } from '../Components/DropdownInputField' - -export const ExportFormatDefault = 'json-gz' -const formatOptions = [ - { - id: 'json-gz', - label: 'Compressed', - }, - { - id: 'json', - label: 'Uncompressed', - }, -] - -export const SelectExportFormat = memo(function SelectExportFormat({ value, setValue }) { - return -}) diff --git a/webui/src/ImportExport/ExportFormat.tsx b/webui/src/ImportExport/ExportFormat.tsx new file mode 100644 index 0000000000..10f9397f54 --- /dev/null +++ b/webui/src/ImportExport/ExportFormat.tsx @@ -0,0 +1,32 @@ +import React, { memo } from 'react' +import { DropdownInputField } from '../Components/DropdownInputField.js' +import type { ExportFormat } from '@companion/shared/Model/ExportFormat.js' +import { DropdownChoice, DropdownChoiceId } from '@companion-module/base' + +export const ExportFormatDefault: ExportFormat = 'json-gz' +const formatOptions: DropdownChoice[] = [ + { + id: 'json-gz', + label: 'Compressed', + }, + { + id: 'json', + label: 'Uncompressed', + }, +] + +interface SelectExportFormatProps { + value: ExportFormat + setValue: (value: ExportFormat) => void +} + +export const SelectExportFormat = memo(function SelectExportFormat({ value, setValue }: SelectExportFormatProps) { + return ( + void} + /> + ) +}) diff --git a/webui/src/ImportExport/Import/Full.jsx b/webui/src/ImportExport/Import/Full.tsx similarity index 85% rename from webui/src/ImportExport/Import/Full.jsx rename to webui/src/ImportExport/Import/Full.tsx index e4a76c5053..10f7945ad7 100644 --- a/webui/src/ImportExport/Import/Full.jsx +++ b/webui/src/ImportExport/Import/Full.tsx @@ -17,23 +17,30 @@ import { faCalendar, faClock, faDownload, faFileImport, faGlobe } from '@fortawe import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { ImportPageWizard } from './Page' import { ImportTriggersTab } from './Triggers' +import { ClientImportObject } from '@companion/shared/Model/ImportExport' -export function ImportFullWizard({ snapshot, instanceRemap, setInstanceRemap }) { +interface ImportFullWizardProps { + snapshot: ClientImportObject + instanceRemap: Record + setInstanceRemap: React.Dispatch>> +} + +export function ImportFullWizard({ snapshot, instanceRemap, setInstanceRemap }: ImportFullWizardProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const doSinglePageImport = useCallback( - (fromPage, toPage, instanceRemap) => { + (fromPage: number, toPage: number, instanceRemap: Record) => { socketEmitPromise(socket, 'loadsave:import-page', [toPage, fromPage, instanceRemap]) .then((res) => { - notifier.current.show(`Import successful`, `Page was imported successfully`, 10000) + notifier.current?.show(`Import successful`, `Page was imported successfully`, 10000) console.log('remap response', res) if (res) { setInstanceRemap(res) } }) .catch((e) => { - notifier.current.show(`Import failed`, `Page import failed with: "${e}"`, 10000) + notifier.current?.show(`Import failed`, `Page import failed with: "${e}"`, 10000) console.error('import failed', e) }) }, @@ -97,12 +104,16 @@ export function ImportFullWizard({ snapshot, instanceRemap, setInstanceRemap }) ) } -function FullImportTab({ snapshot }) { +interface FullImportTabProps { + snapshot: ClientImportObject +} + +function FullImportTab({ snapshot }: FullImportTabProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const snapshotKeys = useMemo(() => { - const keys = [] + const keys: string[] = [] for (const [key, val] of Object.entries(snapshot)) { if (val) keys.push(key) @@ -141,13 +152,13 @@ function FullImportTab({ snapshot }) { const doImport = useCallback(() => { socketEmitPromise(socket, 'loadsave:import-full', [config], 60000) - .then((res) => { + .then((_res) => { // notifier.current.show(`Import successful`, `Page was imported successfully`, 10000) window.location.reload() }) .catch((e) => { console.log('import failed', e) - notifier.current.show(`Import failed`, `Full import failed with: "${e?.message ?? e}"`, 10000) + notifier.current?.show(`Import failed`, `Full import failed with: "${e?.message ?? e}"`, 10000) }) console.log('do import!') }, [socket, notifier, config]) @@ -217,7 +228,15 @@ function FullImportTab({ snapshot }) { ) } -function InputCheckbox({ config, allowKeys, keyName, setValue, label }) { +interface InputCheckboxProps { + config: Record + allowKeys: string[] + keyName: string + setValue: (key: string, value: any) => void + label: string +} + +function InputCheckbox({ config, allowKeys, keyName, setValue, label }: InputCheckboxProps) { const disabled = allowKeys && !allowKeys.includes(keyName) const setValue2 = useCallback((e) => setValue(keyName, !!e.currentTarget.checked), [setValue, keyName]) diff --git a/webui/src/ImportExport/Import/Page.jsx b/webui/src/ImportExport/Import/Page.tsx similarity index 78% rename from webui/src/ImportExport/Import/Page.jsx rename to webui/src/ImportExport/Import/Page.tsx index 37f2e4ecfb..6c9caa38c0 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.tsx @@ -11,13 +11,27 @@ import { } from '../../util' import { ButtonGridHeader } from '../../Buttons/ButtonGridHeader' import { usePagePicker } from '../../Hooks/usePagePicker' -import { ButtonGridIcon, ButtonGridIconBase, ButtonInfiniteGrid } from '../../Buttons/ButtonInfiniteGrid' +import { + ButtonGridIcon, + ButtonGridIconBase, + ButtonInfiniteGrid, + ButtonInfiniteGridButtonProps, + ButtonInfiniteGridRef, +} from '../../Buttons/ButtonInfiniteGrid' import { faHome } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useHasBeenRendered } from '../../Hooks/useHasBeenRendered' +import type { ClientImportObject } from '@companion/shared/Model/ImportExport' import { compareExportedInstances } from '@companion/shared/Import' -export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, doImport }) { +interface ImportPageWizardProps { + snapshot: ClientImportObject + instanceRemap: Record + setInstanceRemap: React.Dispatch>> + doImport: (importPageNumber: number, pageNumber: number, instanceRemap: Record) => void +} + +export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, doImport }: ImportPageWizardProps) { const pages = useContext(PagesContext) const userConfig = useContext(UserConfigContext) @@ -31,7 +45,7 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do } = usePagePicker(pages, 1) const setInstanceRemap2 = useCallback( - (fromId, toId) => { + (fromId: string, toId: string) => { setInstanceRemap((oldRemap) => ({ ...oldRemap, [fromId]: toId, @@ -44,14 +58,14 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do doImport(importPageNumber, pageNumber, instanceRemap) }, [doImport, importPageNumber, pageNumber, instanceRemap]) - const destinationGridSize = userConfig.gridSize + const destinationGridSize = userConfig?.gridSize - const destinationGridRef = useRef(null) + const destinationGridRef = useRef(null) const resetDestinationPosition = useCallback(() => { destinationGridRef.current?.resetPosition() }, [destinationGridRef]) - const sourceGridRef = useRef(null) + const sourceGridRef = useRef(null) const resetSourcePosition = useCallback(() => { sourceGridRef.current?.resetPosition() }, [sourceGridRef]) @@ -82,16 +96,16 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do
- {hasBeenRendered && ( + {hasBeenRendered && sourceGridSize && ( @@ -120,7 +134,7 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do
- {hasBeenRendered && ( + {hasBeenRendered && destinationGridSize && ( + setInstanceRemap: (fromId: string, toId: string) => void +} + +export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }: ImportRemapProps) { const modules = useContext(ModulesContext) const connectionsContext = useContext(ConnectionsContext) @@ -183,14 +203,17 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) { {sortedInstances.map(([key, instance]) => { const snapshotModule = modules[instance.instance_type] const currentInstances = Object.entries(connectionsContext).filter( - ([id, inst]) => inst.instance_type === instance.instance_type + ([_id, inst]) => inst.instance_type === instance.instance_type ) return (
- - - - - diff --git a/webui/src/UserConfig/ArtnetProtocol.jsx b/webui/src/UserConfig/ArtnetProtocol.tsx similarity index 100% rename from webui/src/UserConfig/ArtnetProtocol.jsx rename to webui/src/UserConfig/ArtnetProtocol.tsx diff --git a/webui/src/UserConfig/ButtonsConfig.jsx b/webui/src/UserConfig/ButtonsConfig.tsx similarity index 83% rename from webui/src/UserConfig/ButtonsConfig.jsx rename to webui/src/UserConfig/ButtonsConfig.tsx index ff9c532a8f..23c8922224 100644 --- a/webui/src/UserConfig/ButtonsConfig.jsx +++ b/webui/src/UserConfig/ButtonsConfig.tsx @@ -3,12 +3,19 @@ import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function ButtonsConfig({ config, setValue, resetValue }) { +interface ButtonsConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function ButtonsConfig({ config, setValue, resetValue }: ButtonsConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/EmberPlusConfig.jsx b/webui/src/UserConfig/EmberPlusConfig.tsx similarity index 74% rename from webui/src/UserConfig/EmberPlusConfig.jsx rename to webui/src/UserConfig/EmberPlusConfig.tsx index 3e3887fa79..380f07f313 100644 --- a/webui/src/UserConfig/EmberPlusConfig.jsx +++ b/webui/src/UserConfig/EmberPlusConfig.tsx @@ -3,12 +3,19 @@ import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function EmberPlusConfig({ config, setValue, resetValue }) { +interface EmberPlusConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function EmberPlusConfig({ config, setValue, resetValue }: EmberPlusConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/ExperimentsConfig.jsx b/webui/src/UserConfig/ExperimentsConfig.tsx similarity index 76% rename from webui/src/UserConfig/ExperimentsConfig.jsx rename to webui/src/UserConfig/ExperimentsConfig.tsx index dc8028c08e..97ef9443bc 100644 --- a/webui/src/UserConfig/ExperimentsConfig.jsx +++ b/webui/src/UserConfig/ExperimentsConfig.tsx @@ -1,17 +1,24 @@ import React from 'react' import { CAlert } from '@coreui/react' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function ExperimentsConfig({ config, setValue, resetValue }) { +interface ExperimentsConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function ExperimentsConfig({}: ExperimentsConfigProps) { return ( <> - - diff --git a/webui/src/UserConfig/GridConfig.jsx b/webui/src/UserConfig/GridConfig.tsx similarity index 76% rename from webui/src/UserConfig/GridConfig.jsx rename to webui/src/UserConfig/GridConfig.tsx index 96fd5a4acf..4fb92f452a 100644 --- a/webui/src/UserConfig/GridConfig.jsx +++ b/webui/src/UserConfig/GridConfig.tsx @@ -1,4 +1,13 @@ -import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react' +import React, { + FormEvent, + forwardRef, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' import { CAlert, CButton, @@ -15,9 +24,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCog, faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' import { SocketContext, UserConfigContext } from '../util' +import type { UserConfigGridSize, UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function GridConfig({ config, setValue, resetValue }) { - const gridSizeRef = useRef(null) +interface GridConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function GridConfig({ config, setValue, resetValue }: GridConfigProps) { + const gridSizeRef = useRef(null) const editGridSize = useCallback(() => { gridSizeRef.current?.show() @@ -26,7 +42,7 @@ export function GridConfig({ config, setValue, resetValue }) { return ( <> - @@ -93,14 +109,21 @@ export function GridConfig({ config, setValue, resetValue }) { ) } -const GridSizeModal = forwardRef(function GridSizeModal(props, ref) { +interface GridSizeModalProps { + // Nothing +} +interface GridSizeModalRef { + show(): void +} + +const GridSizeModal = forwardRef(function GridSizeModal(_props, ref) { const socket = useContext(SocketContext) const userConfig = useContext(UserConfigContext) const [show, setShow] = useState(false) - const [newGridSize, setNewGridSize] = useState(null) + const [newGridSize, setNewGridSize] = useState(null) - const buttonRef = useRef() + const buttonRef = useRef() const buttonFocus = () => { if (buttonRef.current) { @@ -113,13 +136,13 @@ const GridSizeModal = forwardRef(function GridSizeModal(props, ref) { setNewGridSize(null) }, []) const doAction = useCallback( - (e) => { + (e: FormEvent) => { if (e) e.preventDefault() setShow(false) setNewGridSize(null) - if (!setNewGridSize) return + if (!newGridSize) return console.log('set gridSize', newGridSize) socket.emit('set_userconfig_key', 'gridSize', newGridSize) @@ -143,7 +166,7 @@ const GridSizeModal = forwardRef(function GridSizeModal(props, ref) { useEffect(() => { if (show) { setNewGridSize((oldGridSize) => { - if (!oldGridSize) return userConfig.gridSize + if (!oldGridSize && userConfig) return userConfig.gridSize return oldGridSize }) } @@ -151,36 +174,52 @@ const GridSizeModal = forwardRef(function GridSizeModal(props, ref) { const setMinColumn = useCallback((e) => { const newValue = Number(e.currentTarget.value) - setNewGridSize((oldSize) => ({ - ...oldSize, - minColumn: newValue, - })) + setNewGridSize((oldSize) => + oldSize + ? { + ...oldSize, + minColumn: newValue, + } + : null + ) }, []) const setMaxColumn = useCallback((e) => { const newValue = Number(e.currentTarget.value) - setNewGridSize((oldSize) => ({ - ...oldSize, - maxColumn: newValue, - })) + setNewGridSize((oldSize) => + oldSize + ? { + ...oldSize, + maxColumn: newValue, + } + : null + ) }, []) const setMinRow = useCallback((e) => { const newValue = Number(e.currentTarget.value) - setNewGridSize((oldSize) => ({ - ...oldSize, - minRow: newValue, - })) + setNewGridSize((oldSize) => + oldSize + ? { + ...oldSize, + minRow: newValue, + } + : null + ) }, []) const setMaxRow = useCallback((e) => { const newValue = Number(e.currentTarget.value) - setNewGridSize((oldSize) => ({ - ...oldSize, - maxRow: newValue, - })) + setNewGridSize((oldSize) => + oldSize + ? { + ...oldSize, + maxRow: newValue, + } + : null + ) }, []) const isReducingSize = newGridSize && - userConfig.gridSize && + userConfig?.gridSize && (newGridSize.minColumn > userConfig.gridSize.minColumn || newGridSize.maxColumn < userConfig.gridSize.maxColumn || newGridSize.minRow > userConfig.gridSize.minRow || diff --git a/webui/src/UserConfig/HttpConfig.jsx b/webui/src/UserConfig/HttpConfig.tsx similarity index 76% rename from webui/src/UserConfig/HttpConfig.jsx rename to webui/src/UserConfig/HttpConfig.tsx index 2de10c930f..734254196c 100644 --- a/webui/src/UserConfig/HttpConfig.jsx +++ b/webui/src/UserConfig/HttpConfig.tsx @@ -1,14 +1,21 @@ import React from 'react' -import { CButton, CInput } from '@coreui/react' +import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function HttpConfig({ config, setValue, resetValue }) { +interface HttpConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function HttpConfig({ config, setValue, resetValue }: HttpConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/HttpProtocol.jsx b/webui/src/UserConfig/HttpProtocol.tsx similarity index 100% rename from webui/src/UserConfig/HttpProtocol.jsx rename to webui/src/UserConfig/HttpProtocol.tsx diff --git a/webui/src/UserConfig/HttpsConfig.jsx b/webui/src/UserConfig/HttpsConfig.tsx similarity index 93% rename from webui/src/UserConfig/HttpsConfig.jsx rename to webui/src/UserConfig/HttpsConfig.tsx index 098d5197c2..0b2fd1a8ef 100644 --- a/webui/src/UserConfig/HttpsConfig.jsx +++ b/webui/src/UserConfig/HttpsConfig.tsx @@ -4,8 +4,15 @@ import { SocketContext } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSync, faTrash, faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function HttpsConfig({ config, setValue, resetValue }) { +interface HttpsConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function HttpsConfig({ config, setValue, resetValue }: HttpsConfigProps) { const socket = useContext(SocketContext) const createSslCertificate = useCallback(() => { @@ -26,12 +33,12 @@ export function HttpsConfig({ config, setValue, resetValue }) { return ( <> - - -
IDNameType 
No itemsNo control surfaces have been detected
#{surface.index}{surface.id} - - {surface.type}{surface.location} - - - Settings - - - {surface.integrationType === 'emulator' && ( - <> - - - - - - - - )} - -
#{group.index}{group.id} + + Group- + + + Settings + + + + Delete + + +
{index !== undefined ? `#${index}` : ''} {surface.id} {surface.type}{surface.isConnected ? surface.location || 'Local' : 'Offline'} - - Forget - + {surface.isConnected ? ( + + + Settings + + + {surface.integrationType === 'emulator' && ( + <> + + + + + + + + )} + + ) : ( + + Forget + + )}
No connections
No control surfaces have been detected
+ There currently are no triggers or scheduled tasks.
{variable.label} - {elms === '' || elms === null || elms === undefined ? ( - '(empty)' - ) : ( - {elms} - )} + { + /*elms === '' || elms === null || elms === undefined */ lines.length === 0 || + valueRaw === undefined || + valueRaw === null ? ( + '(empty)' + ) : ( + {elms} + ) + } diff --git a/webui/src/Components/index.jsx b/webui/src/Components/index.tsx similarity index 92% rename from webui/src/Components/index.jsx rename to webui/src/Components/index.tsx index 8737338339..5a83a7328f 100644 --- a/webui/src/Components/index.jsx +++ b/webui/src/Components/index.tsx @@ -5,4 +5,3 @@ export { CheckboxInputField } from './CheckboxInputField' export { DropdownInputField } from './DropdownInputField' export { TextInputField } from './TextInputField' export { NumberInputField } from './NumberInputField' -export { Card } from './Card' diff --git a/webui/src/ConnectionDebug.jsx b/webui/src/ConnectionDebug.tsx similarity index 81% rename from webui/src/ConnectionDebug.jsx rename to webui/src/ConnectionDebug.tsx index f0af07e2f1..39374dfb92 100644 --- a/webui/src/ConnectionDebug.jsx +++ b/webui/src/ConnectionDebug.tsx @@ -1,17 +1,28 @@ -import { useCallback, useEffect, useState, useContext, memo, useRef, useMemo } from 'react' -import { SocketContext, socketEmitPromise } from './util' +import React, { useCallback, useEffect, useState, useContext, memo, useRef, useMemo } from 'react' +import { SocketContext, socketEmitPromise } from './util.js' import { CButton, CButtonGroup, CCol, CContainer, CRow } from '@coreui/react' import { nanoid } from 'nanoid' import { useParams } from 'react-router-dom' -import { VariableSizeList as List } from 'react-window' +import { VariableSizeList as List, ListOnScrollProps } from 'react-window' import AutoSizer from 'react-virtualized-auto-sizer' import { useElementSize } from 'usehooks-ts' import { stringify as csvStringify } from 'csv-stringify/sync' -const LogsOnDiskInfoLine = { - time: null, +interface DebugLogLine { + level: string + message: string +} + +interface DebugConfig { + debug: boolean | undefined + info: boolean | undefined + warn: boolean | undefined + error: boolean | undefined + console: boolean | undefined +} + +const LogsOnDiskInfoLine: DebugLogLine = { level: 'debug', - source: 'log', message: 'Only recent lines are shown here, nothing is persisted', } @@ -21,7 +32,7 @@ export function ConnectionDebug() { const { id: connectionId } = useParams() // const [loadError, setLoadError]=useState(null) - const [linesBuffer, setLinesBuffer] = useState([]) + const [linesBuffer, setLinesBuffer] = useState([]) // A unique identifier which changes upon each reconnection const [connectionToken, setConnectionToken] = useState(nanoid()) @@ -50,7 +61,7 @@ export function ConnectionDebug() { useEffect(() => { setLinesBuffer([]) - const onNewLines = (level, message) => { + const onNewLines = (level: string, message: string) => { console.log('line', level, message) setLinesBuffer((oldLines) => [...oldLines, { level, message }]) } @@ -62,11 +73,9 @@ export function ConnectionDebug() { if (!info) { onNewLines('system', 'Connection was not found') } - // TODO - console.log('subscried', info) + console.log('subscribed', info) }) .catch((err) => { - //TODO console.error('Subscribe failure', err) }) @@ -94,6 +103,7 @@ export function ConnectionDebug() { 'download', `module-log-${new Date().toLocaleDateString()}-${new Date().toLocaleTimeString()}.csv` ) + // @ts-expect-error `oneTimeOnly` not defined in typings link.href = window.URL.createObjectURL(blob, { oneTimeOnly: true }) document.body.appendChild(link) link.click() @@ -111,13 +121,13 @@ export function ConnectionDebug() { }) }, [socket, connectionId]) - const [config, setConfig] = useState(() => loadConfig(connectionId)) + const [config, setConfig] = useState(() => loadConfig(connectionId ?? '')) // Save the config when it changes useEffect(() => { window.localStorage.setItem(`module_debug:${connectionId}`, JSON.stringify(config)) }, [config, connectionId]) - const doToggleConfig = useCallback((key) => { + const doToggleConfig = useCallback((key: keyof DebugConfig) => { setConfig((oldConfig) => ({ ...oldConfig, [key]: !oldConfig[key], @@ -200,9 +210,16 @@ export function ConnectionDebug() { ) } -function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentWidth }) { - const listRef = useRef(null) - const rowHeights = useRef({}) +interface LogPanelContentsProps { + linesBuffer: DebugLogLine[] + listChunkClearedToken: string + config: DebugConfig + contentWidth: number +} + +function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentWidth }: LogPanelContentsProps) { + const listRef = useRef(null) + const rowHeights = useRef>({}) const [follow, setFollow] = useState(true) @@ -214,7 +231,7 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW }, [listRef, listChunkClearedToken, contentWidth]) const messages = useMemo(() => { - return linesBuffer.filter((msg) => msg.level === 'system' || config[msg.level]) + return linesBuffer.filter((msg) => msg.level === 'system' || !!config[msg.level as keyof DebugConfig]) }, [linesBuffer, config]) useEffect(() => { @@ -227,7 +244,7 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW const hasMountedRef = useRef(false) const userScroll = useCallback( - (event) => { + (event: ListOnScrollProps) => { // Ignore scroll event on mount if (!hasMountedRef.current) { hasMountedRef.current = true @@ -259,19 +276,21 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW ) const getRowHeight = useCallback( - (index) => { + (index: number) => { return rowHeights.current[index] || 18 }, [rowHeights] ) - function setRowHeight(index, size) { - listRef.current.resetAfterIndex(0) + function setRowHeight(index: number, size: number) { + if (listRef.current) { + listRef.current.resetAfterIndex(0) + } rowHeights.current = { ...rowHeights.current, [index]: size } } - function Row({ style, index }) { - const rowRef = useRef({}) + function Row({ style, index }: { style: React.CSSProperties; index: number }) { + const rowRef = useRef(null) const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] @@ -289,7 +308,7 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW ) } - const outerRef = useRef(null) + const outerRef = useRef(null) return ( @@ -310,7 +329,11 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW ) } -const LogLineInner = memo(({ h, innerRef }) => { +interface LogLineInnerProps { + h: DebugLogLine + innerRef: React.RefObject +} +const LogLineInner = memo(({ h, innerRef }: LogLineInnerProps) => { return (
{h.level !== 'console' && ( @@ -323,15 +346,17 @@ const LogLineInner = memo(({ h, innerRef }) => { ) }) -function loadConfig(connectionId) { +function loadConfig(connectionId: string): DebugConfig { const saveId = `module_debug:${connectionId}` try { const rawConfig = window.localStorage.getItem(saveId) if (!rawConfig) throw new Error() - return JSON.parse(rawConfig) ?? {} + const config = JSON.parse(rawConfig) + if (!config) throw new Error() + return config } catch (e) { // setup defaults - const config = { + const config: DebugConfig = { debug: true, info: true, warn: true, diff --git a/webui/src/Connections/AddConnection.jsx b/webui/src/Connections/AddConnection.tsx similarity index 81% rename from webui/src/Connections/AddConnection.jsx rename to webui/src/Connections/AddConnection.tsx index 280543dbac..ef8b3b1c4d 100644 --- a/webui/src/Connections/AddConnection.jsx +++ b/webui/src/Connections/AddConnection.tsx @@ -6,44 +6,53 @@ import { faExclamationTriangle, faQuestionCircle, faTimes } from '@fortawesome/f import { socketEmitPromise, SocketContext, NotifierContext, ModulesContext } from '../util' import { useCallback } from 'react' import { useRef } from 'react' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' +import { ModuleDisplayInfo } from '@companion/shared/Model/Common' -export function AddConnectionsPanel({ showHelp, doConfigureConnection }) { +interface AddConnectionsPanelProps { + showHelp: (moduleId: string) => void + doConfigureConnection: (connectionId: string) => void +} + +export function AddConnectionsPanel({ showHelp, doConfigureConnection }: AddConnectionsPanelProps) { return ( <> - + ) } -const AddConnectionsInner = memo(function AddConnectionsInner({ showHelp, configureConnection }) { +const AddConnectionsInner = memo(function AddConnectionsInner({ + showHelp, + doConfigureConnection, +}: AddConnectionsPanelProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) const modules = useContext(ModulesContext) const [filter, setFilter] = useState('') - const confirmRef = useRef(null) + const confirmRef = useRef(null) const addConnectionInner = useCallback( - (type, product) => { + (type: string, product: string | undefined) => { socketEmitPromise(socket, 'connections:add', [{ type: type, product: product }]) .then((id) => { setFilter('') console.log('NEW CONNECTION', id) - configureConnection(id) + doConfigureConnection(id) }) .catch((e) => { - notifier.current.show(`Failed to create connection`, `Failed: ${e}`) + notifier.current?.show(`Failed to create connection`, `Failed: ${e}`) console.error('Failed to create connection:', e) }) }, - [socket, notifier, configureConnection] + [socket, notifier, doConfigureConnection] ) const addConnection = useCallback( - (type, product, module) => { + (type: string, product: string | undefined, module: ModuleDisplayInfo) => { if (module.isLegacy) { - confirmRef.current.show( + confirmRef.current?.show( `${module.manufacturer} ${product} is outdated`, null, // Passed as param to the thing 'Add anyway', @@ -62,9 +71,9 @@ const AddConnectionsInner = memo(function AddConnectionsInner({ showHelp, config return Object.values(modules).flatMap((module) => module.products.map((product) => ({ product, ...module }))) }, [modules]) - let candidates = [] + let candidates: JSX.Element[] = [] try { - const candidatesObj = {} + const candidatesObj: Record = {} const searchResults = filter ? fuzzySearch(filter, allProducts, { diff --git a/webui/src/Connections/ConnectionEditPanel.jsx b/webui/src/Connections/ConnectionEditPanel.tsx similarity index 73% rename from webui/src/Connections/ConnectionEditPanel.jsx rename to webui/src/Connections/ConnectionEditPanel.tsx index d9260ac984..2ee9faed8e 100644 --- a/webui/src/Connections/ConnectionEditPanel.jsx +++ b/webui/src/Connections/ConnectionEditPanel.tsx @@ -1,16 +1,30 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { LoadingRetryOrError, sandbox, socketEmitPromise, SocketContext, ModulesContext } from '../util' +import { LoadingRetryOrError, sandbox, socketEmitPromise, SocketContext, ModulesContext } from '../util.js' import { CRow, CCol, CButton } from '@coreui/react' -import { ColorInputField, DropdownInputField, NumberInputField, TextInputField } from '../Components' +import { ColorInputField, DropdownInputField, NumberInputField, TextInputField } from '../Components/index.js' import { nanoid } from 'nanoid' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' import sanitizeHtml from 'sanitize-html' -import { isLabelValid } from '@companion/shared/Label' -import CSwitch from '../CSwitch' -import { BonjourDeviceInputField } from '../Components/BonjourDeviceInputField' +import { isLabelValid } from '@companion/shared/Label.js' +import CSwitch from '../CSwitch.js' +import { BonjourDeviceInputField } from '../Components/BonjourDeviceInputField.js' +import { ConnectionStatusEntry } from '@companion/shared/Model/Common.js' +import { SomeCompanionConfigField } from '@companion-module/base' + +interface ConnectionEditPanelProps { + connectionId: string + connectionStatus: ConnectionStatusEntry | undefined + doConfigureConnection: (connectionId: string | null) => void + showHelp: (moduleId: string) => void +} -export function ConnectionEditPanel({ connectionId, connectionStatus, doConfigureConnection, showHelp }) { +export function ConnectionEditPanel({ + connectionId, + connectionStatus, + doConfigureConnection, + showHelp, +}: ConnectionEditPanelProps) { console.log('status', connectionStatus) if (!connectionStatus || !connectionStatus.level || connectionStatus.level === 'crashed') { @@ -33,27 +47,33 @@ export function ConnectionEditPanel({ connectionId, connectionStatus, doConfigur ) } +interface ConnectionEditPanelInnerProps { + connectionId: string + doConfigureConnection: (connectionId: string | null) => void + showHelp: (moduleId: string) => void +} + const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ connectionId, doConfigureConnection, showHelp, -}) { +}: ConnectionEditPanelInnerProps) { const socket = useContext(SocketContext) const modules = useContext(ModulesContext) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) - const [configFields, setConfigFields] = useState(null) - const [connectionConfig, setConnectionConfig] = useState(null) - const [connectionLabel, setConnectionLabel] = useState(null) - const [connectionType, setConnectionType] = useState(null) - const [validFields, setValidFields] = useState(null) + const [configFields, setConfigFields] = useState([]) + const [connectionConfig, setConnectionConfig] = useState | null>(null) + const [connectionLabel, setConnectionLabel] = useState(null) + const [connectionType, setConnectionType] = useState(null) + const [validFields, setValidFields] = useState | null>(null) - const [fieldVisibility, setFieldVisibility] = useState({}) + const [fieldVisibility, setFieldVisibility] = useState>({}) const invalidFieldNames = useMemo(() => { - const fieldNames = [] + const fieldNames: string[] = [] if (validFields) { for (const [field, valid] of Object.entries(validFields)) { @@ -76,7 +96,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ const newLabel = connectionLabel?.trim() - if (!isLabelValid(newLabel) || invalidFieldNames.length > 0) { + if (!newLabel || !isLabelValid(newLabel) || invalidFieldNames.length > 0) { setError(`Some config fields are not valid: ${invalidFieldNames.join(', ')}`) return } @@ -106,7 +126,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ socketEmitPromise(socket, 'connections:edit', [connectionId]) .then((res) => { if (res) { - const validFields = {} + const validFields: Record = {} for (const field of res.fields) { // Real validation status gets generated when the editor components first mount validFields[field.id] = true @@ -142,7 +162,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) - const setValue = useCallback((key, value) => { + const setValue = useCallback((key: string, value: any) => { console.log('set value', key, value) setConnectionConfig((oldConfig) => ({ @@ -150,7 +170,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ [key]: value, })) }, []) - const setValid = useCallback((key, isValid) => { + const setValid = useCallback((key: string, isValid: boolean) => { console.log('set valid', key, isValid) setValidFields((oldValid) => ({ @@ -160,7 +180,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ }, []) useEffect(() => { - const visibility = {} + const visibility: Record = {} if (configFields === null || connectionConfig === null) { return @@ -178,14 +198,14 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ } }, [configFields, connectionConfig]) - const moduleInfo = modules[connectionType] ?? {} - const dataReady = connectionConfig && configFields && validFields + const moduleInfo = connectionType ? modules[connectionType] : undefined + const dataReady = !!connectionConfig && !!configFields && !!validFields return (
{moduleInfo?.shortname ?? connectionType} configuration {moduleInfo?.hasHelp && ( -
showHelp(connectionType)}> +
connectionType && showHelp(connectionType)}>
)} @@ -197,9 +217,9 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ @@ -209,7 +229,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ key={i} className={`fieldtype-${field.type}`} sm={field.width} - style={{ display: fieldVisibility[field.id] === false ? 'none' : null }} + style={{ display: fieldVisibility[field.id] === false ? 'none' : undefined }} > {field.tooltip && ( @@ -218,7 +238,7 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ 0 || !isLabelValid(connectionLabel)} + disabled={ + !validFields || invalidFieldNames.length > 0 || !connectionLabel || !isLabelValid(connectionLabel) + } onClick={doSave} > Save @@ -249,11 +271,20 @@ const ConnectionEditPanelInner = memo(function ConnectionEditPanelInner({ ) }) -function ConfigField({ setValue, setValid, definition, value, connectionId }) { +interface ConfigFieldProps { + setValue: (key: string, value: any) => void + setValid: (key: string, valid: boolean) => void + definition: SomeCompanionConfigField + value: any + connectionId: string +} + +function ConfigField({ setValue, setValid, definition, value, connectionId }: ConfigFieldProps) { const id = definition.id - const setValue2 = useCallback((val) => setValue(id, val), [setValue, id]) - const setValid2 = useCallback((valid) => setValid(id, valid), [setValid, id]) + const setValue2 = useCallback((val: any) => setValue(id, val), [setValue, id]) + const setValid2 = useCallback((valid: boolean) => setValid(id, valid), [setValid, id]) + const fieldType = definition.type switch (definition.type) { case 'static-text': { const descriptionHtml = { @@ -320,11 +351,11 @@ function ConfigField({ setValue, setValid, definition, value, connectionId }) { return ( ) default: - return

Unknown field "{definition.type}"

+ return

Unknown field "{fieldType}"

} } diff --git a/webui/src/Connections/ConnectionList.jsx b/webui/src/Connections/ConnectionList.tsx similarity index 80% rename from webui/src/Connections/ConnectionList.jsx rename to webui/src/Connections/ConnectionList.tsx index 15a7f002ee..253e0f86e2 100644 --- a/webui/src/Connections/ConnectionList.jsx +++ b/webui/src/Connections/ConnectionList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react' import { CButton, CButtonGroup } from '@coreui/react' import { ConnectionsContext, @@ -6,7 +6,7 @@ import { socketEmitPromise, SocketContext, ModulesContext, -} from '../util' +} from '../util.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, @@ -20,37 +20,57 @@ import { faEyeSlash, } from '@fortawesome/free-solid-svg-icons' -import { ConnectionVariablesModal } from './ConnectionVariablesModal' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import CSwitch from '../CSwitch' +import { ConnectionVariablesModal, ConnectionVariablesModalRef } from './ConnectionVariablesModal.js' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' +import CSwitch from '../CSwitch.js' import { useDrag, useDrop } from 'react-dnd' -import { windowLinkOpen } from '../Helpers/Window' +import { windowLinkOpen } from '../Helpers/Window.js' import classNames from 'classnames' +import type { ClientConnectionConfig, ConnectionStatusEntry } from '@companion/shared/Model/Common.js' -export function ConnectionsList({ showHelp, doConfigureConnection, connectionStatus, selectedConnectionId }) { +interface VisibleConnectionsState { + disabled: boolean + ok: boolean + warning: boolean + error: boolean +} + +interface ConnectionsListProps { + showHelp: (connectionId: string) => void + doConfigureConnection: (connectionId: string | null) => void + connectionStatus: Record + selectedConnectionId: string | null +} + +export function ConnectionsList({ + showHelp, + doConfigureConnection, + connectionStatus, + selectedConnectionId, +}: ConnectionsListProps) { const socket = useContext(SocketContext) const connectionsContext = useContext(ConnectionsContext) - const connectionsRef = useRef(null) + const connectionsRef = useRef>() useEffect(() => { connectionsRef.current = connectionsContext }, [connectionsContext]) - const deleteModalRef = useRef() - const variablesModalRef = useRef() + const deleteModalRef = useRef(null) + const variablesModalRef = useRef(null) - const doShowVariables = useCallback((connectionId) => { - variablesModalRef.current.show(connectionId) + const doShowVariables = useCallback((connectionId: string) => { + variablesModalRef.current?.show(connectionId) }, []) - const [visibleConnections, setVisibleConnections] = useState(() => loadVisibility()) + const [visibleConnections, setVisibleConnections] = useState(() => loadVisibility()) // Save the config when it changes useEffect(() => { window.localStorage.setItem('connections_visible', JSON.stringify(visibleConnections)) }, [visibleConnections]) - const doToggleVisibility = useCallback((key) => { + const doToggleVisibility = useCallback((key: keyof VisibleConnectionsState) => { setVisibleConnections((oldConfig) => ({ ...oldConfig, [key]: !oldConfig[key], @@ -204,7 +224,7 @@ export function ConnectionsList({ showHelp, doConfigureConnection, connectionSta ) } -function loadVisibility() { +function loadVisibility(): VisibleConnectionsState { try { const rawConfig = window.localStorage.getItem('connections_visible') if (rawConfig !== null) { @@ -213,7 +233,7 @@ function loadVisibility() { } catch (e) {} // setup defaults - const config = { + const config: VisibleConnectionsState = { disabled: true, ok: true, warning: true, @@ -225,6 +245,25 @@ function loadVisibility() { return config } +interface ConnectionDragItem { + id: string +} +interface ConnectionDragStatus { + isDragging: boolean +} + +interface ConnectionsTableRowProps { + id: string + connection: ClientConnectionConfig + connectionStatus: ConnectionStatusEntry | undefined + showHelp: (connectionId: string) => void + showVariables: (label: string) => void + configureConnection: (connectionId: string | null) => void + deleteModalRef: RefObject + moveRow: (itemId: string, targetId: string) => void + isSelected: boolean +} + function ConnectionsTableRow({ id, connection, @@ -235,7 +274,7 @@ function ConnectionsTableRow({ deleteModalRef, moveRow, isSelected, -}) { +}: ConnectionsTableRowProps) { const socket = useContext(SocketContext) const modules = useContext(ModulesContext) const variableDefinitionsContext = useContext(VariableDefinitionsContext) @@ -245,7 +284,7 @@ function ConnectionsTableRow({ const isEnabled = connection.enabled === undefined || connection.enabled const doDelete = useCallback(() => { - deleteModalRef.current.show( + deleteModalRef.current?.show( 'Delete connection', `Are you sure you want to delete "${connection.label}"?`, 'Delete', @@ -269,9 +308,9 @@ function ConnectionsTableRow({ const doShowVariables = useCallback(() => showVariables(connection.label), [showVariables, connection.label]) const ref = useRef(null) - const [, drop] = useDrop({ + const [, drop] = useDrop({ accept: 'connection', - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -284,7 +323,7 @@ function ConnectionsTableRow({ moveRow(item.id, id) }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: 'connection', item: { id, @@ -357,7 +396,7 @@ function ConnectionsTableRow({
windowLinkOpen({ href: moduleInfo?.bugUrl })} + onClick={() => windowLinkOpen({ href: moduleInfo?.bugUrl })} size="md" title="Issue Tracker" disabled={!moduleInfo?.bugUrl} @@ -381,7 +420,7 @@ function ConnectionsTableRow({ windowLinkOpen({ href: `/connection-debug/${id}`, title: 'View debug log' })} + onClick={() => windowLinkOpen({ href: `/connection-debug/${id}`, title: 'View debug log' })} size="md" title="Logs" disabled={!isEnabled} @@ -410,7 +449,12 @@ function ConnectionsTableRow({ ) } -function ModuleStatusCall({ isEnabled, status }) { +interface ModuleStatusCallProps { + isEnabled: boolean + status: ConnectionStatusEntry | undefined +} + +function ModuleStatusCall({ isEnabled, status }: ModuleStatusCallProps) { if (isEnabled) { const messageStr = !!status && diff --git a/webui/src/Connections/ConnectionVariablesModal.jsx b/webui/src/Connections/ConnectionVariablesModal.jsx deleted file mode 100644 index 8a3515de35..0000000000 --- a/webui/src/Connections/ConnectionVariablesModal.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' -import { CModal, CModalBody, CModalHeader, CModalFooter, CButton, CRow, CCol } from '@coreui/react' -import { VariablesTable } from '../Components/VariablesTable' - -export const ConnectionVariablesModal = forwardRef(function HelpModal(_props, ref) { - const [connectionLabel, setConnectionLabel] = useState(null) - const [show, setShow] = useState(false) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setConnectionLabel(null), []) - - useImperativeHandle( - ref, - () => ({ - show(label) { - setConnectionLabel(label) - setShow(true) - }, - }), - [] - ) - - return ( - - -
Variables for {connectionLabel}
-
- - - - - - - - - - Close - - -
- ) -}) diff --git a/webui/src/Connections/ConnectionVariablesModal.tsx b/webui/src/Connections/ConnectionVariablesModal.tsx new file mode 100644 index 0000000000..682abf4909 --- /dev/null +++ b/webui/src/Connections/ConnectionVariablesModal.tsx @@ -0,0 +1,46 @@ +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import { CModal, CModalBody, CModalHeader, CModalFooter, CButton, CRow, CCol } from '@coreui/react' +import { VariablesTable } from '../Components/VariablesTable' + +export interface ConnectionVariablesModalRef { + show(label: string): void +} + +export const ConnectionVariablesModal = forwardRef( + function ConnectionVariablesModal(_props, ref) { + const [connectionLabel, setConnectionLabel] = useState(null) + const [show, setShow] = useState(false) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => setConnectionLabel(null), []) + + useImperativeHandle( + ref, + () => ({ + show(label) { + setConnectionLabel(label) + setShow(true) + }, + }), + [] + ) + + return ( + + +
Variables for {connectionLabel}
+
+ + + {connectionLabel && } + + + + + Close + + +
+ ) + } +) diff --git a/webui/src/Connections/HelpModal.jsx b/webui/src/Connections/HelpModal.tsx similarity index 71% rename from webui/src/Connections/HelpModal.jsx rename to webui/src/Connections/HelpModal.tsx index 1d8eee18de..2dcde3bd97 100644 --- a/webui/src/Connections/HelpModal.jsx +++ b/webui/src/Connections/HelpModal.tsx @@ -5,11 +5,24 @@ import { Marked } from 'marked' import { baseUrl } from 'marked-base-url' import { ModulesContext } from '../util' +interface HelpModalProps { + // Nothing +} + +interface HelpDescription { + markdown: string + baseUrl: string +} + +export interface HelpModalRef { + show(name: string, description: HelpDescription): void +} + export const HelpModal = memo( - forwardRef(function HelpModal(_props, ref) { + forwardRef(function HelpModal(_props, ref) { const modules = useContext(ModulesContext) - const [content, setContent] = useState(null) + const [content, setContent] = useState<[name: string, description: HelpDescription] | null>(null) const [show, setShow] = useState(false) const doClose = useCallback(() => setShow(false), []) @@ -35,21 +48,20 @@ export const HelpModal = memo( const html = content ? { - __html: sanitizeHtml(marked.parse(content[1].markdown), { + __html: sanitizeHtml(marked.parse(content[1].markdown) as string, { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), disallowedTagsMode: 'escape', }), } : undefined - const moduleInfo = modules?.[content?.[0]] + const moduleInfo = content && modules?.[content[0]] return (
- Help for {moduleInfo?.description || moduleInfo?.name || content?.[0]}{' '} - {moduleInfo?.version ? `v${moduleInfo.version}` : ''} + Help for {moduleInfo?.name || content?.[0]} {moduleInfo?.version ? `v${moduleInfo.version}` : ''}
diff --git a/webui/src/Connections/index.jsx b/webui/src/Connections/index.tsx similarity index 81% rename from webui/src/Connections/index.jsx rename to webui/src/Connections/index.tsx index 49afd26313..4873b26a66 100644 --- a/webui/src/Connections/index.jsx +++ b/webui/src/Connections/index.tsx @@ -1,6 +1,6 @@ import { CCol, CRow, CTabs, CTabContent, CTabPane, CNavItem, CNavLink, CNav } from '@coreui/react' -import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' -import { HelpModal } from './HelpModal' +import React, { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { HelpModal, HelpModalRef } from './HelpModal' import { NotifierContext, MyErrorBoundary, socketEmitPromise, SocketContext } from '../util' import { ConnectionsList } from './ConnectionList' import { AddConnectionsPanel } from './AddConnection' @@ -8,19 +8,20 @@ import { ConnectionEditPanel } from './ConnectionEditPanel' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { nanoid } from 'nanoid' import { faCog, faPlus } from '@fortawesome/free-solid-svg-icons' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { cloneDeep } from 'lodash-es' +import { ConnectionStatusEntry } from '@companion/shared/Model/Common' export const ConnectionsPage = memo(function ConnectionsPage() { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) - const helpModalRef = useRef() + const helpModalRef = useRef(null) const [tabResetToken, setTabResetToken] = useState(nanoid()) const [activeTab, setActiveTab] = useState('add') - const [selectedConnectionId, setSelectedConnectionId] = useState(null) - const doChangeTab = useCallback((newTab) => { + const [selectedConnectionId, setSelectedConnectionId] = useState(null) + const doChangeTab = useCallback((newTab: string) => { setActiveTab((oldTab) => { if (oldTab !== newTab) { setSelectedConnectionId(null) @@ -31,10 +32,10 @@ export const ConnectionsPage = memo(function ConnectionsPage() { }, []) const showHelp = useCallback( - (id) => { + (id: string) => { socketEmitPromise(socket, 'connections:get-help', [id]).then(([err, result]) => { if (err) { - notifier.current.show('Instance help', `Failed to get help text: ${err}`) + notifier.current?.show('Instance help', `Failed to get help text: ${err}`) return } if (result) { @@ -45,13 +46,13 @@ export const ConnectionsPage = memo(function ConnectionsPage() { [socket, notifier] ) - const doConfigureConnection = useCallback((id) => { - setSelectedConnectionId(id) + const doConfigureConnection = useCallback((connectionId: string | null) => { + setSelectedConnectionId(connectionId) setTabResetToken(nanoid()) - setActiveTab(id ? 'edit' : 'add') + setActiveTab(connectionId ? 'edit' : 'add') }, []) - const [connectionStatus, setConnectionStatus] = useState(null) + const [connectionStatus, setConnectionStatus] = useState>({}) useEffect(() => { socketEmitPromise(socket, 'connections:get-statuses', []) .then((statuses) => { @@ -61,7 +62,7 @@ export const ConnectionsPage = memo(function ConnectionsPage() { console.error(`Failed to load connection statuses`, e) }) - const patchStatuses = (patch) => { + const patchStatuses = (patch: JsonPatchOperation[]) => { setConnectionStatus((oldStatuses) => { if (!oldStatuses) return oldStatuses return jsonPatch.applyPatch(cloneDeep(oldStatuses) || {}, patch).newDocument diff --git a/webui/src/Constants.js b/webui/src/Constants.ts similarity index 57% rename from webui/src/Constants.js rename to webui/src/Constants.ts index 63eb509f0d..4831636548 100644 --- a/webui/src/Constants.js +++ b/webui/src/Constants.ts @@ -1,9 +1,10 @@ +import { DropdownChoice } from '@companion-module/base' import { Buffer } from 'buffer' // Hack for csv library which needs a global 'Buffer' window.Buffer = Buffer -export const FONT_SIZES = [ +export const FONT_SIZES: DropdownChoice[] = [ { id: 'auto', label: 'Auto' }, { id: '7', label: '7pt' }, { id: '14', label: '14pt' }, @@ -13,10 +14,10 @@ export const FONT_SIZES = [ { id: '44', label: '44pt' }, ] -export const SHOW_HIDE_TOP_BAR = [ +export const SHOW_HIDE_TOP_BAR: DropdownChoice[] = [ { id: 'default', label: 'Follow Default' }, - { id: true, label: 'Show' }, - { id: false, label: 'Hide' }, + { id: true as any, label: 'Show' }, + { id: false as any, label: 'Hide' }, ] -export const PRIMARY_COLOR = '#d50215' +export const PRIMARY_COLOR: string = '#d50215' diff --git a/webui/src/ContextData.jsx b/webui/src/ContextData.tsx similarity index 63% rename from webui/src/ContextData.jsx rename to webui/src/ContextData.tsx index a43b65c1fa..58936b1707 100644 --- a/webui/src/ContextData.jsx +++ b/webui/src/ContextData.tsx @@ -19,31 +19,49 @@ import { RecentActionsContext, RecentFeedbacksContext, } from './util' -import { NotificationsManager } from './Components/Notifications' +import { NotificationsManager, NotificationsManagerRef } from './Components/Notifications' import { cloneDeep } from 'lodash-es' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { useUserConfigSubscription } from './Hooks/useUserConfigSubscription' import { usePagesInfoSubscription } from './Hooks/usePagesInfoSubscription' +import type { ClientConnectionConfig, ClientEventDefinition, ModuleDisplayInfo } from '@companion/shared/Model/Common' +import type { ClientActionDefinition, InternalFeedbackDefinition } from '@companion/shared/Model/Options' +import type { AllVariableDefinitions, ModuleVariableDefinitions } from '@companion/shared/Model/Variables' +import type { CustomVariablesModel } from '@companion/shared/Model/CustomVariableModel' +import type { ClientDevicesListItem } from '@companion/shared/Model/Surfaces' +import type { ClientTriggerData } from '@companion/shared/Model/TriggerModel' + +interface ContextDataProps { + children: (progressPercent: number, loadingComplete: boolean) => React.JSX.Element | React.JSX.Element[] +} -export function ContextData({ children }) { +export function ContextData({ children }: ContextDataProps) { const socket = useContext(SocketContext) - const [eventDefinitions, setEventDefinitions] = useState(null) - const [instances, setInstances] = useState(null) - const [modules, setModules] = useState(null) - const [actionDefinitions, setActionDefinitions] = useState(null) - const [feedbackDefinitions, setFeedbackDefinitions] = useState(null) - const [variableDefinitions, setVariableDefinitions] = useState(null) - const [customVariables, setCustomVariables] = useState(null) - const [surfaces, setSurfaces] = useState(null) - const [triggers, setTriggers] = useState(null) - - const [recentActions, setRecentActions] = useState(() => { + const [eventDefinitions, setEventDefinitions] = useState | null>( + null + ) + const [connections, setConnections] = useState | null>(null) + const [modules, setModules] = useState | null>(null) + const [actionDefinitions, setActionDefinitions] = useState | undefined + > | null>(null) + const [feedbackDefinitions, setFeedbackDefinitions] = useState | undefined + > | null>(null) + const [variableDefinitions, setVariableDefinitions] = useState(null) + const [customVariables, setCustomVariables] = useState(null) + const [surfaces, setSurfaces] = useState | null>(null) + const [triggers, setTriggers] = useState | null>(null) + + const [recentActions, setRecentActions] = useState(() => { const recent = JSON.parse(window.localStorage.getItem('recent_actions') || '[]') return Array.isArray(recent) ? recent : [] }) - const trackRecentAction = useCallback((actionType) => { + const trackRecentAction = useCallback((actionType: string) => { setRecentActions((existing) => { const newActions = [actionType, ...existing.filter((v) => v !== actionType)].slice(0, 20) @@ -60,12 +78,12 @@ export function ContextData({ children }) { [recentActions, trackRecentAction] ) - const [recentFeedbacks, setRecentFeedbacks] = useState(() => { + const [recentFeedbacks, setRecentFeedbacks] = useState(() => { const recent = JSON.parse(window.localStorage.getItem('recent_feedbacks') || '[]') return Array.isArray(recent) ? recent : [] }) - const trackRecentFeedback = useCallback((feedbackType) => { + const trackRecentFeedback = useCallback((feedbackType: string) => { setRecentFeedbacks((existing) => { const newFeedbacks = [feedbackType, ...existing.filter((v) => v !== feedbackType)].slice(0, 20) @@ -82,10 +100,10 @@ export function ContextData({ children }) { [recentFeedbacks, trackRecentFeedback] ) - const completeVariableDefinitions = useMemo(() => { + const completeVariableDefinitions = useMemo(() => { if (variableDefinitions) { // Generate definitions for all the custom variables - const customVariableDefinitions = {} + const customVariableDefinitions: ModuleVariableDefinitions = {} for (const [id, info] of Object.entries(customVariables || {})) { customVariableDefinitions[`custom_${id}`] = { label: info.description, @@ -100,7 +118,7 @@ export function ContextData({ children }) { }, } } else { - return null + return {} } }, [customVariables, variableDefinitions]) @@ -114,7 +132,7 @@ export function ContextData({ children }) { setEventDefinitions(definitions) }) .catch((e) => { - console.error('Failed to load event definitions') + console.error('Failed to load event definitions', e) }) socketEmitPromise(socket, 'modules:subscribe', []) @@ -153,48 +171,56 @@ export function ContextData({ children }) { console.error('Failed to load custom values list', e) }) - const updateVariableDefinitions = (label, patch) => { - setVariableDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, label, patch)) + const updateVariableDefinitions = (label: string, patch: JsonPatchOperation[]) => { + setVariableDefinitions( + (oldDefinitions) => + oldDefinitions && + applyPatchOrReplaceSubObject(oldDefinitions, label, patch, {}) + ) } - const updateFeedbackDefinitions = (id, patch) => { - setFeedbackDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, id, patch)) + const updateFeedbackDefinitions = (id: string, patch: JsonPatchOperation[]) => { + setFeedbackDefinitions( + (oldDefinitions) => oldDefinitions && applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {}) + ) } - const updateActionDefinitions = (id, patch) => { - setActionDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, id, patch)) + const updateActionDefinitions = (id: string, patch: JsonPatchOperation[]) => { + setActionDefinitions( + (oldDefinitions) => oldDefinitions && applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {}) + ) } - const updateCustomVariables = (patch) => { - setCustomVariables((oldVariables) => applyPatchOrReplaceObject(oldVariables, patch)) + const updateCustomVariables = (patch: JsonPatchOperation[]) => { + setCustomVariables((oldVariables) => oldVariables && applyPatchOrReplaceObject(oldVariables, patch)) } - const updateTriggers = (controlId, patch) => { + const updateTriggers = (controlId: string, patch: JsonPatchOperation[]) => { console.log('trigger', controlId, patch) - setTriggers((oldTriggers) => applyPatchOrReplaceSubObject(oldTriggers, controlId, patch)) + setTriggers((oldTriggers) => oldTriggers && applyPatchOrReplaceSubObject(oldTriggers, controlId, patch, null)) } socketEmitPromise(socket, 'connections:subscribe', []) - .then((instances) => { - setInstances(instances) + .then((connections) => { + setConnections(connections) }) .catch((e) => { console.error('Failed to load instances list:', e) - setInstances(null) + setConnections(null) }) - const patchInstances = (patch) => { - setInstances((oldInstances) => { + const patchInstances = (patch: JsonPatchOperation[] | false) => { + setConnections((oldConnections) => { if (patch === false) { - return false + return {} } else { - return jsonPatch.applyPatch(cloneDeep(oldInstances) || {}, patch).newDocument + return jsonPatch.applyPatch(cloneDeep(oldConnections) || {}, patch).newDocument } }) } socket.on('connections:patch', patchInstances) - const patchModules = (patch) => { + const patchModules = (patch: JsonPatchOperation[] | false) => { setModules((oldModules) => { if (patch === false) { - return false + return {} } else { return jsonPatch.applyPatch(cloneDeep(oldModules) || {}, patch).newDocument } @@ -216,9 +242,9 @@ export function ContextData({ children }) { console.error('Failed to load surfaces', e) }) - const patchSurfaces = (patch) => { + const patchSurfaces = (patch: JsonPatchOperation[]) => { setSurfaces((oldSurfaces) => { - return jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument + return oldSurfaces && jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument }) } socket.on('surfaces:patch', patchSurfaces) @@ -270,14 +296,16 @@ export function ContextData({ children }) { console.error('Failed to unsubscribe from custom variables', e) }) } + } else { + return } }, [socket]) - const notifierRef = useRef() + const notifierRef = useRef(null) const steps = [ eventDefinitions, - instances, + connections, modules, variableDefinitions, completeVariableDefinitions, @@ -295,17 +323,17 @@ export function ContextData({ children }) { return ( - - - - - + + + + + - + - - - + + + diff --git a/webui/src/Controls/ActionSetEditor.jsx b/webui/src/Controls/ActionSetEditor.tsx similarity index 71% rename from webui/src/Controls/ActionSetEditor.jsx rename to webui/src/Controls/ActionSetEditor.tsx index 53d57e00ea..eca3ef1efb 100644 --- a/webui/src/Controls/ActionSetEditor.jsx +++ b/webui/src/Controls/ActionSetEditor.tsx @@ -8,14 +8,13 @@ import { faFolderOpen, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, memo, useCallback, useContext, useMemo, useRef } from 'react' import { NumberInputField } from '../Components' import { ActionsContext, ConnectionsContext, MyErrorBoundary, socketEmitPromise, - sandbox, SocketContext, PreventDefaultHandler, RecentActionsContext, @@ -23,12 +22,27 @@ import { import Select, { createFilter } from 'react-select' import { OptionsInputField } from './OptionsInputField' import { useDrag, useDrop } from 'react-dnd' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { AddActionsModal } from './AddModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' +import { AddActionsModal, AddActionsModalRef } from './AddModal' import { usePanelCollapseHelper } from '../Helpers/CollapseHelper' import CSwitch from '../CSwitch' import { OptionButtonPreview } from './OptionButtonPreview' import { MenuPortalContext } from '../Components/DropdownInputField' +import type { FilterOptionOption } from 'react-select/dist/declarations/src/filters' +import { ActionInstance } from '@companion/shared/Model/ActionModel' +import { ControlLocation } from '@companion/shared/Model/Common' +import { useOptionsAndIsVisible } from '../Hooks/useOptionsAndIsVisible' + +interface ControlActionSetEditorProps { + controlId: string + location: ControlLocation | undefined + stepId: string + setId: string | number + actions: ActionInstance[] | undefined + addPlaceholder: string + heading: JSX.Element | string + headingActions?: JSX.Element[] +} export const ControlActionSetEditor = memo(function ControlActionSetEditor({ controlId, @@ -39,13 +53,13 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ addPlaceholder, heading, headingActions, -}) { +}: ControlActionSetEditorProps) { const socket = useContext(SocketContext) - const confirmModal = useRef() + const confirmModal = useRef(null) const emitUpdateOption = useCallback( - (actionId, key, val) => { + (actionId: string, key: string, val: any) => { socketEmitPromise(socket, 'controls:action:set-option', [controlId, stepId, setId, actionId, key, val]).catch( (e) => { console.error('Failed to set control action option', e) @@ -55,7 +69,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ [socket, controlId, stepId, setId] ) const emitSetDelay = useCallback( - (actionId, delay) => { + (actionId: string, delay: number) => { socketEmitPromise(socket, 'controls:action:set-delay', [controlId, stepId, setId, actionId, delay]).catch((e) => { console.error('Failed to set control action delay', e) }) @@ -64,7 +78,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ ) const emitDelete = useCallback( - (actionId) => { + (actionId: string) => { socketEmitPromise(socket, 'controls:action:remove', [controlId, stepId, setId, actionId]).catch((e) => { console.error('Failed to remove control action', e) }) @@ -72,7 +86,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ [socket, controlId, stepId, setId] ) const emitDuplicate = useCallback( - (actionId) => { + (actionId: string) => { socketEmitPromise(socket, 'controls:action:duplicate', [controlId, stepId, setId, actionId]).catch((e) => { console.error('Failed to duplicate control action', e) }) @@ -81,7 +95,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ ) const emitLearn = useCallback( - (actionId) => { + (actionId: string) => { socketEmitPromise(socket, 'controls:action:learn', [controlId, stepId, setId, actionId]).catch((e) => { console.error('Failed to learn control action values', e) }) @@ -90,7 +104,14 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ ) const emitOrder = useCallback( - (dragStepId, dragSetId, dragIndex, dropStepId, dropSetId, dropIndex) => { + ( + dragStepId: string, + dragSetId: string | number, + dragIndex: number, + dropStepId: string, + dropSetId: string | number, + dropIndex: number + ) => { socketEmitPromise(socket, 'controls:action:reorder', [ controlId, dragStepId, @@ -107,7 +128,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ ) const emitEnabled = useCallback( - (actionId, enabled) => { + (actionId: string, enabled: boolean) => { socketEmitPromise(socket, 'controls:action:enabled', [controlId, stepId, setId, actionId, enabled]).catch((e) => { console.error('Failed to enable/disable action', e) }) @@ -116,7 +137,7 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ ) const addAction = useCallback( - (actionType) => { + (actionType: string) => { const [connectionId, actionId] = actionType.split(':', 2) socketEmitPromise(socket, 'controls:action:add', [controlId, stepId, setId, connectionId, actionId]).catch( (e) => { @@ -152,7 +173,6 @@ export const ControlActionSetEditor = memo(function ControlActionSetEditor({ void +} + +const AddActionsPanel = memo(function AddActionsPanel({ addPlaceholder, addAction }: AddActionsPanelProps) { + const addActionsRef = useRef(null) const showAddModal = useCallback(() => { - if (addActionsRef.current) { - addActionsRef.current.show() - } + addActionsRef.current?.show() }, []) return ( @@ -195,9 +218,34 @@ const AddActionsPanel = memo(function AddActionsPanel({ addPlaceholder, addActio ) }) +interface ActionsListProps { + location: ControlLocation | undefined + dragId: string + stepId: string + setId: string | number + confirmModal?: RefObject + actions: ActionInstance[] | undefined + doSetValue: (actionId: string, key: string, val: any) => void + doSetDelay: (actionId: string, delay: number) => void + doDelete: (actionId: string) => void + doDuplicate: (actionId: string) => void + doEnabled?: (actionId: string, enabled: boolean) => void + doReorder: ( + stepId: string, + setId: string | number, + index: number, + targetStepId: string, + targetSetId: string | number, + targetIndex: number + ) => void + emitLearn?: (actionId: string) => void + readonly?: boolean + setPanelCollapsed: (panelId: string, collapsed: boolean) => void + isPanelCollapsed: (panelId: string) => boolean +} + export function ActionsList({ location, - controlId, dragId, stepId, setId, @@ -213,11 +261,11 @@ export function ActionsList({ readonly, setPanelCollapsed, isPanelCollapsed, -}) { +}: ActionsListProps) { const doDelete2 = useCallback( (actionId) => { if (confirmModal) { - confirmModal.current.show('Delete action', 'Delete action?', 'Delete', () => { + confirmModal.current?.show('Delete action', 'Delete action?', 'Delete', () => { doDelete(actionId) }) } else { @@ -240,7 +288,6 @@ export function ActionsList({ index={i} stepId={stepId} setId={setId} - controlId={controlId} dragId={dragId} setValue={doSetValue} doDelete={doDelete2} @@ -267,18 +314,33 @@ export function ActionsList({ ) } -function ActionRowDropPlaceholder({ dragId, stepId, setId, actionCount, moveCard }) { - const [isDragging, drop] = useDrop({ +interface ActionRowDropPlaceholderProps { + stepId: string + setId: string | number + dragId: string + actionCount: number + moveCard: ( + stepId: string, + setId: string | number, + index: number, + targetStepId: string, + targetSetId: string | number, + targetIndex: number + ) => void +} + +function ActionRowDropPlaceholder({ dragId, stepId, setId, actionCount, moveCard }: ActionRowDropPlaceholderProps) { + const [isDragging, drop] = useDrop({ accept: dragId, collect: (monitor) => { return monitor.canDrop() }, - hover(item, monitor) { + hover(item, _monitor) { moveCard(item.stepId, item.setId, item.index, stepId, setId, 0) }, }) - if (!isDragging || actionCount > 0) return <> + if (!isDragging || actionCount > 0) return null return (
{snapshotModule ? ( - setInstanceRemap(key, e.target.value)}> + setInstanceRemap(key, e.currentTarget.value)} + > {currentInstances.map(([id, inst]) => ( @@ -214,7 +237,7 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) { ) } -function ButtonImportPreview({ ...props }) { +function ButtonImportPreview({ ...props }: ButtonInfiniteGridButtonProps) { const socket = useContext(SocketContext) const [previewImage, setPreviewImage] = useState(null) diff --git a/webui/src/ImportExport/Import/Triggers.jsx b/webui/src/ImportExport/Import/Triggers.tsx similarity index 76% rename from webui/src/ImportExport/Import/Triggers.jsx rename to webui/src/ImportExport/Import/Triggers.tsx index a8dba6f4f8..aadf20c869 100644 --- a/webui/src/ImportExport/Import/Triggers.jsx +++ b/webui/src/ImportExport/Import/Triggers.tsx @@ -1,20 +1,27 @@ import { CButton, CButtonGroup, CInputCheckbox } from '@coreui/react' -import React from 'react' +import React, { ChangeEvent } from 'react' import { useCallback } from 'react' import { useEffect } from 'react' import { useState } from 'react' import { ImportRemap } from './Page' import { NotifierContext, SocketContext, socketEmitPromise } from '../../util' import { useContext } from 'react' +import type { ClientImportObject } from '@companion/shared/Model/ImportExport' -export function ImportTriggersTab({ snapshot, instanceRemap, setInstanceRemap }) { +interface ImportTriggersTabProps { + snapshot: ClientImportObject + instanceRemap: Record + setInstanceRemap: React.Dispatch>> +} + +export function ImportTriggersTab({ snapshot, instanceRemap, setInstanceRemap }: ImportTriggersTabProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) - const [selectedTriggers, setSelectedTriggers] = useState([]) + const [selectedTriggers, setSelectedTriggers] = useState([]) const setInstanceRemap2 = useCallback( - (fromId, toId) => { + (fromId: string, toId: string) => { setInstanceRemap((oldRemap) => ({ ...oldRemap, [fromId]: toId, @@ -23,12 +30,15 @@ export function ImportTriggersTab({ snapshot, instanceRemap, setInstanceRemap }) [setInstanceRemap] ) - const selectAllTriggers = useCallback(() => setSelectedTriggers(Object.keys(snapshot.triggers)), [snapshot.triggers]) + const selectAllTriggers = useCallback( + () => setSelectedTriggers(Object.keys(snapshot.triggers ?? {})), + [snapshot.triggers] + ) const unselectAllTriggers = useCallback(() => setSelectedTriggers([]), []) useEffect(() => selectAllTriggers(), [selectAllTriggers]) - const toggleTrigger = useCallback((e) => { + const toggleTrigger = useCallback((e: ChangeEvent) => { const id = e.target.getAttribute('data-id') const checked = e.target.checked if (id) { @@ -50,14 +60,14 @@ export function ImportTriggersTab({ snapshot, instanceRemap, setInstanceRemap }) socketEmitPromise(socket, 'loadsave:import-triggers', [selectedTriggers, instanceRemap, doReplace]) .then((res) => { - notifier.current.show(`Import successful`, `Triggers were imported successfully`, 10000) + notifier.current?.show(`Import successful`, `Triggers were imported successfully`, 10000) console.log('remap response', res) if (res) { setInstanceRemap(res) } }) .catch((e) => { - notifier.current.show(`Import failed`, `Triggers import failed with: "${e}"`, 10000) + notifier.current?.show(`Import failed`, `Triggers import failed with: "${e}"`, 10000) console.error('import failed', e) }) }, diff --git a/webui/src/ImportExport/Import/index.jsx b/webui/src/ImportExport/Import/index.tsx similarity index 70% rename from webui/src/ImportExport/Import/index.jsx rename to webui/src/ImportExport/Import/index.tsx index faeded910f..0439715c82 100644 --- a/webui/src/ImportExport/Import/index.jsx +++ b/webui/src/ImportExport/Import/index.tsx @@ -3,8 +3,14 @@ import React, { useCallback, useContext, useEffect, useState } from 'react' import { NotifierContext, SocketContext, socketEmitPromise } from '../../util' import { ImportPageWizard } from './Page' import { ImportFullWizard } from './Full' +import type { ClientImportObject } from '@companion/shared/Model/ImportExport' -export function ImportWizard({ importInfo, clearImport }) { +interface ImportWizardProps { + importInfo: [ClientImportObject, Record] + clearImport: () => void +} + +export function ImportWizard({ importInfo, clearImport }: ImportWizardProps) { const socket = useContext(SocketContext) const notifier = useContext(NotifierContext) @@ -16,10 +22,10 @@ export function ImportWizard({ importInfo, clearImport }) { }, [instanceRemap0]) const doSinglePageImport = useCallback( - (fromPage, toPage, instanceRemap) => { + (fromPage: number, toPage: number, instanceRemap: Record) => { socketEmitPromise(socket, 'loadsave:import-page', [toPage, fromPage, instanceRemap]) - .then((res) => { - notifier.current.show(`Import successful`, `Page was imported successfully`, 10000) + .then((_res) => { + notifier.current?.show(`Import successful`, `Page was imported successfully`, 10000) clearImport() // console.log('remap response', res) // if (res) { @@ -27,7 +33,7 @@ export function ImportWizard({ importInfo, clearImport }) { // } }) .catch((e) => { - notifier.current.show(`Import failed`, `Page import failed with: "${e}"`, 10000) + notifier.current?.show(`Import failed`, `Page import failed with: "${e}"`, 10000) console.error('import failed', e) }) }, diff --git a/webui/src/ImportExport/Reset.jsx b/webui/src/ImportExport/Reset.tsx similarity index 54% rename from webui/src/ImportExport/Reset.jsx rename to webui/src/ImportExport/Reset.tsx index 61fa68f124..378e5f7178 100644 --- a/webui/src/ImportExport/Reset.jsx +++ b/webui/src/ImportExport/Reset.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' +import React, { FormEvent, forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' import { CButton, CForm, @@ -13,167 +13,182 @@ import { import { NotifierContext, PreventDefaultHandler, SocketContext, socketEmitPromise } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDownload } from '@fortawesome/free-solid-svg-icons' +import type { ClientResetSelection } from '@companion/shared/Model/ImportExport' -export const ResetWizardModal = forwardRef(function WizardModal(_props, ref) { - const socket = useContext(SocketContext) - const notifier = useContext(NotifierContext) - - const [currentStep, setCurrentStep] = useState(1) - const maxSteps = 3 - const applyStep = 3 - const [clear, setClear] = useState(true) - const [show, setShow] = useState(false) - const [config, setConfig] = useState({}) - - const doClose = useCallback(() => { - setShow(false) - setClear(true) - }, []) - - const doNextStep = useCallback(() => { - let newStep = currentStep - // Make sure step is set to something reasonable - if (newStep >= maxSteps - 1) { - newStep = maxSteps - } else { - newStep = newStep + 1 - } +interface ResetWizardModalProps {} +export interface ResetWizardModalRef { + show(): void +} - setCurrentStep(newStep) - }, [currentStep, maxSteps]) +export const ResetWizardModal = forwardRef( + function WizardModal(_props, ref) { + const socket = useContext(SocketContext) + const notifier = useContext(NotifierContext) - const doPrevStep = useCallback(() => { - let newStep = currentStep - if (newStep <= 1) { - newStep = 1 - } else { - newStep = newStep - 1 - } + const [currentStep, setCurrentStep] = useState(1) + const maxSteps = 3 + const applyStep = 3 + const [clear, setClear] = useState(true) + const [show, setShow] = useState(false) + const [config, setConfig] = useState({ + connections: true, + buttons: true, + surfaces: true, + triggers: true, + customVariables: true, + userconfig: true, + }) - setCurrentStep(newStep) - }, [currentStep]) - - const doSave = useCallback( - (e) => { - e.preventDefault() - - socketEmitPromise(socket, 'loadsave:reset', [config], 30000) - .then((status) => { - if (status !== 'ok') { - notifier.current.show( - `Reset failed`, - `An unspecified error occurred during the reset. Please try again.`, - 10000 - ) - } + const doClose = useCallback(() => { + setShow(false) + setClear(true) + }, []) - doClose() - }) - .catch((e) => { - notifier.current.show(`Reset failed`, 'An error occurred:' + e, 10000) - doNextStep() - }) + const doNextStep = useCallback(() => { + let newStep = currentStep + // Make sure step is set to something reasonable + if (newStep >= maxSteps - 1) { + newStep = maxSteps + } else { + newStep = newStep + 1 + } - doNextStep() - }, - [socket, notifier, config, doNextStep, doClose] - ) + setCurrentStep(newStep) + }, [currentStep, maxSteps]) - const setValue = (key, value) => { - setConfig((oldState) => ({ - ...oldState, - [key]: value, - })) - } + const doPrevStep = useCallback(() => { + let newStep = currentStep + if (newStep <= 1) { + newStep = 1 + } else { + newStep = newStep - 1 + } + + setCurrentStep(newStep) + }, [currentStep]) - useImperativeHandle( - ref, - () => ({ - show() { - if (clear) { - setConfig({ - connections: true, - buttons: true, - surfaces: true, - triggers: true, - customVariables: true, - userconfig: true, + const doSave = useCallback( + (e: FormEvent) => { + e.preventDefault() + + socketEmitPromise(socket, 'loadsave:reset', [config], 30000) + .then((status) => { + if (status !== 'ok') { + notifier.current?.show( + `Reset failed`, + `An unspecified error occurred during the reset. Please try again.`, + 10000 + ) + } + + doClose() + }) + .catch((e) => { + notifier.current?.show(`Reset failed`, 'An error occurred:' + e, 10000) + doNextStep() }) - setCurrentStep(1) - } - setShow(true) - setClear(false) + doNextStep() }, - }), - [clear] - ) + [socket, notifier, config, doNextStep, doClose] + ) - let nextButton - switch (currentStep) { - case applyStep: - nextButton = ( - - Apply - - ) - break - case maxSteps: - nextButton = ( - - Finish - - ) - break - default: - nextButton = ( - - Next - - ) - } + const setValue = (key: keyof ClientResetSelection, value: boolean) => { + setConfig((oldState) => ({ + ...oldState, + [key]: value, + })) + } - let modalBody - switch (currentStep) { - case 1: - modalBody = - break - case 2: - modalBody = - break - case 3: - modalBody = - break - default: - } + useImperativeHandle( + ref, + () => ({ + show() { + if (clear) { + setConfig({ + connections: true, + buttons: true, + surfaces: true, + triggers: true, + customVariables: true, + userconfig: true, + }) - return ( - - - -

- logo - Reset Configuration -

-
- {modalBody} - - {currentStep <= applyStep && ( - <> - - Cancel - - - Back - - - )} - {nextButton} - -
-
- ) -}) + setCurrentStep(1) + } + setShow(true) + setClear(false) + }, + }), + [clear] + ) + + let nextButton + switch (currentStep) { + case applyStep: + nextButton = ( + + Apply + + ) + break + case maxSteps: + nextButton = ( + + Finish + + ) + break + default: + nextButton = ( + + Next + + ) + } + + let modalBody + switch (currentStep) { + case 1: + modalBody = + break + case 2: + modalBody = + break + case 3: + modalBody = + break + default: + } + + return ( + + + +

+ logo + Reset Configuration +

+
+ {modalBody} + + {currentStep <= applyStep && ( + <> + + Cancel + + + Back + + + )} + {nextButton} + +
+
+ ) + } +) function ResetBeginStep() { return ( @@ -190,7 +205,12 @@ function ResetBeginStep() { ) } -function ResetOptionsStep({ config, setValue }) { +interface ResetOptionsStepProps { + config: ClientResetSelection + setValue: (key: keyof ClientResetSelection, value: boolean) => void +} + +function ResetOptionsStep({ config, setValue }: ResetOptionsStepProps) { return (
Reset Options
@@ -275,7 +295,11 @@ function ResetOptionsStep({ config, setValue }) { ) } -function ResetApplyStep({ config }) { +interface ResetApplyStepProps { + config: ClientResetSelection +} + +function ResetApplyStep({ config }: ResetApplyStepProps) { const changes = [] if (config.connections && !config.buttons && !config.triggers) { diff --git a/webui/src/ImportExport/index.jsx b/webui/src/ImportExport/index.tsx similarity index 77% rename from webui/src/ImportExport/index.jsx rename to webui/src/ImportExport/index.tsx index f92e26b85b..86ff8b6dbc 100644 --- a/webui/src/ImportExport/index.jsx +++ b/webui/src/ImportExport/index.tsx @@ -1,25 +1,26 @@ -import React, { useCallback, useContext, useRef, useState } from 'react' +import React, { FormEvent, useCallback, useContext, useRef, useState } from 'react' import { ConnectionsContext, SocketContext, socketEmitPromise } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDownload, faFileImport, faTrashAlt } from '@fortawesome/free-solid-svg-icons' import { CAlert, CButton } from '@coreui/react' -import { ResetWizardModal } from './Reset' -import { ExportWizardModal } from './Export' +import { ResetWizardModal, ResetWizardModalRef } from './Reset' +import { ExportWizardModal, ExportWizardModalRef } from './Export' import { ImportWizard } from './Import' +import type { ClientImportObject } from '@companion/shared/Model/ImportExport' export function ImportExport() { const socket = useContext(SocketContext) const connectionsContext = useContext(ConnectionsContext) - const [loadError, setLoadError] = useState(null) + const [loadError, setLoadError] = useState(null) - const resetRef = useRef(null) - const exportRef = useRef(null) - const doReset = useCallback(() => resetRef.current.show(), []) - const doExport = useCallback(() => exportRef.current.show(), []) + const resetRef = useRef(null) + const exportRef = useRef(null) + const doReset = useCallback(() => resetRef.current?.show(), []) + const doExport = useCallback(() => exportRef.current?.show(), []) - const [importInfo, setImportInfo] = useState(null) + const [importInfo, setImportInfo] = useState<[ClientImportObject, Record] | null>(null) const clearImport = useCallback(() => { setImportInfo(null) @@ -31,9 +32,9 @@ export function ImportExport() { const fileApiIsSupported = !!(window.File && window.FileReader && window.FileList && window.Blob) const loadSnapshot = useCallback( - (e) => { + (e: FormEvent) => { const newFile = e.currentTarget.files?.[0] - e.currentTarget.value = null + e.currentTarget.value = null as any if (newFile === undefined || newFile.type === undefined) { setLoadError('Unable to read config file') @@ -44,14 +45,16 @@ export function ImportExport() { fr.onload = () => { setLoadError(null) socketEmitPromise(socket, 'loadsave:prepare-import', [fr.result], 20000) - .then(([err, config]) => { + .then(([err, config]: [string | null, ClientImportObject]) => { if (err) { setLoadError(err) } else { - const initialRemap = {} + const initialRemap: Record = {} // Figure out some initial mappings. Look for matching type and hopefully label - for (const [id, obj] of Object.entries(config.instances)) { + for (const [id, obj] of Object.entries(config.instances ?? {})) { + if (!obj) continue + const candidateIds = [] let matchingLabelId = '' diff --git a/webui/src/Layout/Header.jsx b/webui/src/Layout/Header.tsx similarity index 62% rename from webui/src/Layout/Header.jsx rename to webui/src/Layout/Header.tsx index 4d51f8198d..dd5e28e7a3 100644 --- a/webui/src/Layout/Header.jsx +++ b/webui/src/Layout/Header.tsx @@ -1,31 +1,38 @@ import React, { useContext, useEffect, useState } from 'react' import { CHeader, CHeaderBrand, CHeaderNavItem, CHeaderNav, CHeaderNavLink, CToggler } from '@coreui/react' -import { SocketContext, socketEmitPromise } from '../util' +import { SocketContext, socketEmitPromise } from '../util.js' import { faLock } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import type { AppUpdateInfo, AppVersionInfo } from '@companion/shared/Model/Common.js' -export function MyHeader({ toggleSidebar, canLock, setLocked }) { +interface MyHeaderProps { + toggleSidebar: () => void + canLock: boolean + setLocked: (locked: boolean) => void +} + +export function MyHeader({ toggleSidebar, canLock, setLocked }: MyHeaderProps) { const socket = useContext(SocketContext) - const [versionInfo, setVersionInfo] = useState(null) - const [updateData, setUpdateData] = useState(null) + const [versionInfo, setVersionInfo] = useState(null) + const [updateData, setUpdateData] = useState(null) useEffect(() => { - if (socket) { - socket.on('app-update-info', setUpdateData) - socket.emit('app-update-info') + if (!socket) return + + socket.on('app-update-info', setUpdateData) + socket.emit('app-update-info') - socketEmitPromise(socket, 'app-version-info', []) - .then((info) => { - setVersionInfo(info) - }) - .catch((e) => { - console.error('Failed to load version info', e) - }) + socketEmitPromise(socket, 'app-version-info', []) + .then((info) => { + setVersionInfo(info) + }) + .catch((e) => { + console.error('Failed to load version info', e) + }) - return () => { - socket.off('app-update-info', setUpdateData) - } + return () => { + socket.off('app-update-info', setUpdateData) } }, [socket]) diff --git a/webui/src/Layout/Sidebar.jsx b/webui/src/Layout/Sidebar.tsx similarity index 96% rename from webui/src/Layout/Sidebar.jsx rename to webui/src/Layout/Sidebar.tsx index e6afe8c3bc..91fbc71345 100644 --- a/webui/src/Layout/Sidebar.jsx +++ b/webui/src/Layout/Sidebar.tsx @@ -12,7 +12,12 @@ import { } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -export const MySidebar = memo(function MySidebar({ show, showWizard }) { +interface MySidebarProps { + show: boolean + showWizard: () => void +} + +export const MySidebar = memo(function MySidebar({ show, showWizard }: MySidebarProps) { return ( diff --git a/webui/src/LogPanel.jsx b/webui/src/LogPanel.tsx similarity index 79% rename from webui/src/LogPanel.jsx rename to webui/src/LogPanel.tsx index 112a056ad0..df4d0f0798 100644 --- a/webui/src/LogPanel.jsx +++ b/webui/src/LogPanel.tsx @@ -1,15 +1,26 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { CButton, CButtonGroup, CCol, CRow } from '@coreui/react' -import { socketEmitPromise, SocketContext } from './util' +import { socketEmitPromise, SocketContext } from './util.js' import { nanoid } from 'nanoid' import dayjs from 'dayjs' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faFileExport } from '@fortawesome/free-solid-svg-icons' -import { GenericConfirmModal } from './Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from './Components/GenericConfirmModal.js' import { VariableSizeList as List } from 'react-window' import AutoSizer from 'react-virtualized-auto-sizer' +import type { ClientLogLine } from '@companion/shared/Model/LogLine.js' -const LogsOnDiskInfoLine = { +interface LogConfig { + debug: boolean | undefined + info: boolean | undefined + warn: boolean | undefined +} + +interface ClientLogLineExt extends Omit { + time: number | null +} + +const LogsOnDiskInfoLine: ClientLogLineExt = { time: null, level: 'debug', source: 'log', @@ -18,8 +29,8 @@ const LogsOnDiskInfoLine = { export const LogPanel = memo(function LogPanel() { const socket = useContext(SocketContext) - const [config, setConfig] = useState(() => loadConfig()) - const exportRef = useRef() + const [config, setConfig] = useState(() => loadConfig()) + const exportRef = useRef(null) // Save the config when it changes useEffect(() => { @@ -32,7 +43,7 @@ export const LogPanel = memo(function LogPanel() { }) }, [socket]) - const doToggleConfig = useCallback((key) => { + const doToggleConfig = useCallback((key: keyof LogConfig) => { setConfig((oldConfig) => ({ ...oldConfig, [key]: !oldConfig[key], @@ -44,7 +55,7 @@ export const LogPanel = memo(function LogPanel() { const doToggleDebug = useCallback(() => doToggleConfig('debug'), [doToggleConfig]) const exportSupportModal = useCallback(() => { - exportRef.current.show( + exportRef.current?.show( 'Export Support Bundle', [ 'This packages up your recent Companion logs, configuration and backups.', @@ -114,16 +125,19 @@ export const LogPanel = memo(function LogPanel() { ) }) -function LogPanelContents({ config }) { +interface LogPanelContentsProps { + config: LogConfig +} +function LogPanelContents({ config }: LogPanelContentsProps) { const socket = useContext(SocketContext) - const [history, setHistory] = useState([]) + const [history, setHistory] = useState([]) const [listChunkClearedToken, setListChunkClearedToken] = useState(nanoid()) // on 'Mount' setup useEffect(() => { const getClearLog = () => setHistory([]) - const logRecv = (rawItems) => { + const logRecv = (rawItems: ClientLogLine[]) => { if (!rawItems || rawItems.length === 0) return const newItems = rawItems.map((item) => ({ ...item, id: nanoid() })) @@ -141,7 +155,7 @@ function LogPanelContents({ config }) { } socketEmitPromise(socket, 'logs:subscribe', []) - .then((lines) => { + .then((lines: ClientLogLine[]) => { const items = lines.map((item) => ({ ...item, id: nanoid(), @@ -165,8 +179,8 @@ function LogPanelContents({ config }) { }) } }, [socket]) - const listRef = useRef(null) - const rowHeights = useRef({}) + const listRef = useRef(null) + const rowHeights = useRef>({}) const [follow, setFollow] = useState(true) @@ -178,7 +192,7 @@ function LogPanelContents({ config }) { }, [config, listRef, listChunkClearedToken]) const messages = useMemo(() => { - return history.filter((msg) => msg.level === 'error' || config[msg.level]) + return history.filter((msg) => msg.level === 'error' || !!config[msg.level as keyof LogConfig]) }, [history, config]) useEffect(() => { @@ -229,13 +243,15 @@ function LogPanelContents({ config }) { [rowHeights] ) - function setRowHeight(index, size) { - listRef.current.resetAfterIndex(0) + function setRowHeight(index: number, size: number) { + if (listRef.current) { + listRef.current.resetAfterIndex(0) + } rowHeights.current = { ...rowHeights.current, [index]: size } } - function Row({ style, index }) { - const rowRef = useRef({}) + function Row({ style, index }: { style: React.CSSProperties; index: number }) { + const rowRef = useRef(null) const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] @@ -253,7 +269,7 @@ function LogPanelContents({ config }) { ) } - const outerRef = useRef(null) + const outerRef = useRef(null) return ( @@ -274,7 +290,11 @@ function LogPanelContents({ config }) { ) } -const LogLineInner = memo(({ h, innerRef }) => { +interface LogLineInnerProps { + h: ClientLogLineExt + innerRef: React.RefObject +} +const LogLineInner = memo(({ h, innerRef }: LogLineInnerProps) => { const time_format = h.time === null ? ' ' : dayjs(h.time).format('YY.MM.DD HH:mm:ss') return (
@@ -283,13 +303,16 @@ const LogLineInner = memo(({ h, innerRef }) => { ) }) -function loadConfig() { +function loadConfig(): LogConfig { try { const rawConfig = window.localStorage.getItem('debug_config') - return JSON.parse(rawConfig) ?? {} + if (!rawConfig) throw new Error() + const config = JSON.parse(rawConfig) + if (!config) throw new Error() + return config } catch (e) { // setup defaults - const config = { + const config: LogConfig = { debug: false, info: false, warn: true, diff --git a/webui/src/Surfaces/AddGroupModal.jsx b/webui/src/Surfaces/AddGroupModal.jsx deleted file mode 100644 index 1d9de27890..0000000000 --- a/webui/src/Surfaces/AddGroupModal.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' -import { - CButton, - CForm, - CFormGroup, - CInput, - CLabel, - CModal, - CModalBody, - CModalFooter, - CModalHeader, -} from '@coreui/react' -import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' - -export const AddSurfaceGroupModal = forwardRef(function SurfaceEditModal(_props, ref) { - const socket = useContext(SocketContext) - - const [show, setShow] = useState(false) - - const [groupName, setGroupName] = useState(null) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setGroupName(null) - }, []) - - const doAction = useCallback( - (e) => { - if (e) e.preventDefault() - - if (!groupName) return - - setShow(false) - setGroupName(null) - - socketEmitPromise(socket, 'surfaces:group-add', [groupName]).catch((err) => { - console.error('Group add failed', err) - }) - }, - [groupName] - ) - - useImperativeHandle( - ref, - () => ({ - show() { - setShow(true) - setGroupName('My group') - }, - }), - [] - ) - - const onNameChange = useCallback((e) => setGroupName(e.target.value), []) - - return ( - - -
Add Surface Group
-
- - - - Name - - - - - - - Cancel - - - Save - - -
- ) -}) diff --git a/webui/src/Surfaces/AddGroupModal.tsx b/webui/src/Surfaces/AddGroupModal.tsx new file mode 100644 index 0000000000..cb5d8839d9 --- /dev/null +++ b/webui/src/Surfaces/AddGroupModal.tsx @@ -0,0 +1,96 @@ +import React, { + ChangeEvent, + FormEvent, + forwardRef, + useCallback, + useContext, + useImperativeHandle, + useState, +} from 'react' +import { + CButton, + CForm, + CFormGroup, + CInput, + CLabel, + CModal, + CModalBody, + CModalFooter, + CModalHeader, +} from '@coreui/react' +import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' + +export interface AddSurfaceGroupModalRef { + show(): void +} +interface AddSurfaceGroupModalProps { + // Nothing +} + +export const AddSurfaceGroupModal = forwardRef( + function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + + const [show, setShow] = useState(false) + + const [groupName, setGroupName] = useState(null) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setGroupName(null) + }, []) + + const doAction = useCallback( + (e: FormEvent) => { + if (e) e.preventDefault() + + if (!groupName) return + + setShow(false) + setGroupName(null) + + socketEmitPromise(socket, 'surfaces:group-add', [groupName]).catch((err) => { + console.error('Group add failed', err) + }) + }, + [groupName] + ) + + useImperativeHandle( + ref, + () => ({ + show() { + setShow(true) + setGroupName('My group') + }, + }), + [] + ) + + const onNameChange = useCallback((e: ChangeEvent) => setGroupName(e.currentTarget.value), []) + + return ( + + +
Add Surface Group
+
+ + + + Name + + + + + + + Cancel + + + Save + + +
+ ) + } +) diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx deleted file mode 100644 index 1d0a3bec86..0000000000 --- a/webui/src/Surfaces/EditModal.jsx +++ /dev/null @@ -1,436 +0,0 @@ -import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from 'react' -import { - CButton, - CForm, - CFormGroup, - CInput, - CInputCheckbox, - CLabel, - CModal, - CModalBody, - CModalFooter, - CModalHeader, - CSelect, -} from '@coreui/react' -import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler, SurfacesContext } from '../util' -import { nanoid } from 'nanoid' -import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { InternalInstanceField } from '../Controls/InternalInstanceFields' -import { MenuPortalContext } from '../Components/DropdownInputField' - -const PAGE_FIELD_SPEC = { type: 'internal:page', includeDirection: false } - -export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { - const socket = useContext(SocketContext) - const surfacesContext = useContext(SurfacesContext) - - const [rawGroupId, setGroupId] = useState(null) - const [surfaceId, setSurfaceId] = useState(null) - const [show, setShow] = useState(false) - - let surfaceInfo = null - if (surfaceId) { - for (const group of Object.values(surfacesContext)) { - if (surfaceInfo) break - if (!group) continue - - for (const surface of group.surfaces) { - if (surface.id === surfaceId) { - surfaceInfo = { - ...surface, - groupId: group.isAutoGroup ? null : group.id, - } - break - } - } - } - } - - const groupId = surfaceInfo && !surfaceInfo.groupId ? surfaceId : rawGroupId - let groupInfo = null - if (groupId) { - for (const group of Object.values(surfacesContext)) { - if (group && group.id === groupId) { - groupInfo = group - break - } - } - } - - const [surfaceConfig, setSurfaceConfig] = useState(null) - const [groupConfig, setGroupConfig] = useState(null) - const [configLoadError, setConfigLoadError] = useState(null) - const [reloadToken, setReloadToken] = useState(nanoid()) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setSurfaceId(null) - setSurfaceConfig(null) - setConfigLoadError(null) - }, []) - - const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) - - useEffect(() => { - setConfigLoadError(null) - setSurfaceConfig(null) - setGroupConfig(null) - - if (surfaceId) { - socketEmitPromise(socket, 'surfaces:config-get', [surfaceId]) - .then((config) => { - setSurfaceConfig(config) - }) - .catch((err) => { - console.error('Failed to load surface config') - setConfigLoadError(`Failed to load surface config`) - }) - } - if (groupId) { - socketEmitPromise(socket, 'surfaces:group-config-get', [groupId]) - .then((config) => { - setGroupConfig(config) - }) - .catch((err) => { - console.error('Failed to load group config') - setConfigLoadError(`Failed to load surface group config`) - }) - } - }, [socket, surfaceId, groupId, reloadToken]) - - useImperativeHandle( - ref, - () => ({ - show(surfaceId, groupId) { - setSurfaceId(surfaceId) - setGroupId(groupId) - setShow(true) - }, - }), - [] - ) - - useEffect(() => { - // If surface disappears/disconnects, hide this - - const onlineSurfaceIds = new Set() - for (const group of Object.values(surfacesContext)) { - if (!group) continue - for (const surface of group.surfaces) { - if (surface.isConnected) { - onlineSurfaceIds.add(surface.id) - } - } - } - - setSurfaceId((oldSurfaceId) => { - if (oldSurfaceId && !onlineSurfaceIds.has(oldSurfaceId)) { - setShow(false) - } - return oldSurfaceId - }) - }, [surfacesContext]) - - const setSurfaceConfigValue = useCallback( - (key, value) => { - console.log('update surface', key, value) - if (surfaceId) { - setSurfaceConfig((oldConfig) => { - const newConfig = { - ...oldConfig, - [key]: value, - } - - socketEmitPromise(socket, 'surfaces:config-set', [surfaceId, newConfig]) - .then((newConfig) => { - if (typeof newConfig === 'string') { - console.log('Config update failed', newConfig) - } else { - setSurfaceConfig(newConfig) - } - }) - .catch((e) => { - console.log('Config update failed', e) - }) - return newConfig - }) - } - }, - [socket, surfaceId] - ) - const setGroupConfigValue = useCallback( - (key, value) => { - console.log('update group', key, value) - if (groupId) { - socketEmitPromise(socket, 'surfaces:group-config-set', [groupId, key, value]) - .then((newConfig) => { - if (typeof newConfig === 'string') { - console.log('group config update failed', newConfig) - } else { - setGroupConfig(newConfig) - } - }) - .catch((e) => { - console.log('group config update failed', e) - }) - - setGroupConfig((oldConfig) => { - return { - ...oldConfig, - [key]: value, - } - }) - } - }, - [socket, groupId] - ) - - const setSurfaceGroupId = useCallback( - (groupId) => { - if (!groupId || groupId === 'null') groupId = null - socketEmitPromise(socket, 'surfaces:add-to-group', [groupId, surfaceId]).catch((e) => { - console.log('Config update failed', e) - }) - }, - [socket, surfaceId] - ) - - const [modalRef, setModalRef] = useState(null) - - return ( - - - -
Settings for {surfaceInfo?.displayName ?? surfaceInfo?.type ?? groupInfo?.displayName}
-
- - - - - {surfaceInfo && ( - - - Surface Group  - - - setSurfaceGroupId(e.currentTarget.value)} - > - - - {Object.values(surfacesContext) - .filter((group) => group && !group.isAutoGroup) - .map((group) => ( - - ))} - - - )} - - {groupConfig && ( - <> - - Use Last Page At Startup - setGroupConfigValue('use_last_page', !!e.currentTarget.checked)} - /> - - - Startup Page - - {InternalInstanceField( - PAGE_FIELD_SPEC, - false, - !!groupConfig.use_last_page, - groupConfig.startup_page, - (val) => setGroupConfigValue('startup_page', val) - )} - - - Current Page - - {InternalInstanceField(PAGE_FIELD_SPEC, false, false, groupConfig.last_page, (val) => - setGroupConfigValue('last_page', val) - )} - - - )} - - {surfaceConfig && surfaceInfo && ( - <> - {surfaceInfo.configFields?.includes('emulator_size') && ( - <> - - Row count - setSurfaceConfigValue('emulator_rows', parseInt(e.currentTarget.value))} - /> - - - Column count - setSurfaceConfigValue('emulator_columns', parseInt(e.currentTarget.value))} - /> - - - )} - - - Horizontal Offset in grid - setSurfaceConfigValue('xOffset', parseInt(e.currentTarget.value))} - /> - - - Vertical Offset in grid - setSurfaceConfigValue('yOffset', parseInt(e.currentTarget.value))} - /> - - - {surfaceInfo.configFields?.includes('brightness') && ( - - Brightness - setSurfaceConfigValue('brightness', parseInt(e.currentTarget.value))} - /> - - )} - {surfaceInfo.configFields?.includes('illuminate_pressed') && ( - - Illuminate pressed buttons - setSurfaceConfigValue('illuminate_pressed', !!e.currentTarget.checked)} - /> - - )} - - - Button rotation - { - const valueNumber = parseInt(e.currentTarget.value) - setSurfaceConfigValue('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) - }} - > - - - - - - {surfaceInfo.configFields?.includes('legacy_rotation') && ( - <> - - - - - )} - - - {surfaceInfo.configFields?.includes('emulator_control_enable') && ( - - Enable support for Logitech R400/Mastercue/DSan - setSurfaceConfigValue('emulator_control_enable', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( - - Prompt to enter fullscreen - setSurfaceConfigValue('emulator_prompt_fullscreen', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('videohub_page_count') && ( - - Page Count - setSurfaceConfigValue('videohub_page_count', parseInt(e.currentTarget.value))} - /> - - )} - - Never Pin code lock - setSurfaceConfigValue('never_lock', !!e.currentTarget.checked)} - /> - - - )} - - - - - Close - - -
-
- ) -}) diff --git a/webui/src/Surfaces/EditModal.tsx b/webui/src/Surfaces/EditModal.tsx new file mode 100644 index 0000000000..bdecd774b4 --- /dev/null +++ b/webui/src/Surfaces/EditModal.tsx @@ -0,0 +1,447 @@ +import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from 'react' +import { + CButton, + CForm, + CFormGroup, + CInput, + CInputCheckbox, + CLabel, + CModal, + CModalBody, + CModalFooter, + CModalHeader, + CSelect, +} from '@coreui/react' +import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler, SurfacesContext } from '../util' +import { nanoid } from 'nanoid' +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { InternalInstanceField } from '../Controls/InternalInstanceFields' +import { MenuPortalContext } from '../Components/DropdownInputField' +import { ClientDevicesListItem } from '@companion/shared/Model/Surfaces' +import { InternalInputField } from '@companion/shared/Model/Options' + +const PAGE_FIELD_SPEC: InternalInputField = { + id: '', + type: 'internal:page', + label: '', + includeDirection: false, + default: 0, +} + +export interface SurfaceEditModalRef { + show(surfaceId: string | null, groupId: string | null): void +} +interface SurfaceEditModalProps { + // Nothing +} + +export const SurfaceEditModal = forwardRef( + function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + const surfacesContext = useContext(SurfacesContext) + + const [rawGroupId, setGroupId] = useState(null) + const [surfaceId, setSurfaceId] = useState(null) + const [show, setShow] = useState(false) + + let surfaceInfo = null + if (surfaceId) { + for (const group of Object.values(surfacesContext)) { + if (surfaceInfo || !group) break + + for (const surface of group.surfaces) { + if (surface.id === surfaceId) { + surfaceInfo = { + ...surface, + groupId: group.isAutoGroup ? null : group.id, + } + break + } + } + } + } + + const groupId = surfaceInfo && !surfaceInfo.groupId ? surfaceId : rawGroupId + let groupInfo = null + if (groupId) { + for (const group of Object.values(surfacesContext)) { + if (group && group.id === groupId) { + groupInfo = group + break + } + } + } + + const [surfaceConfig, setSurfaceConfig] = useState | null>(null) + const [groupConfig, setGroupConfig] = useState | null>(null) + const [configLoadError, setConfigLoadError] = useState(null) + const [reloadToken, setReloadToken] = useState(nanoid()) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setSurfaceId(null) + setSurfaceConfig(null) + setConfigLoadError(null) + }, []) + + const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) + + useEffect(() => { + setConfigLoadError(null) + setSurfaceConfig(null) + setGroupConfig(null) + + if (surfaceId) { + socketEmitPromise(socket, 'surfaces:config-get', [surfaceId]) + .then((config) => { + setSurfaceConfig(config) + }) + .catch((err: any) => { + console.error('Failed to load surface config', err) + setConfigLoadError(`Failed to load surface config`) + }) + } + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-get', [groupId]) + .then((config) => { + setGroupConfig(config) + }) + .catch((err: any) => { + console.error('Failed to load group config', err) + setConfigLoadError(`Failed to load surface group config`) + }) + } + }, [socket, surfaceId, groupId, reloadToken]) + + useImperativeHandle( + ref, + () => ({ + show(surfaceId, groupId) { + setSurfaceId(surfaceId) + setGroupId(groupId) + setShow(true) + }, + }), + [] + ) + + useEffect(() => { + // If surface disappears/disconnects, hide this + + const onlineSurfaceIds = new Set() + for (const group of Object.values(surfacesContext)) { + if (!group) continue + for (const surface of group.surfaces) { + if (surface.isConnected) { + onlineSurfaceIds.add(surface.id) + } + } + } + + setSurfaceId((oldSurfaceId) => { + if (oldSurfaceId && !onlineSurfaceIds.has(oldSurfaceId)) { + setShow(false) + } + return oldSurfaceId + }) + }, [surfacesContext]) + + const setSurfaceConfigValue = useCallback( + (key: string, value: any) => { + console.log('update surface', key, value) + if (surfaceId) { + setSurfaceConfig((oldConfig) => { + const newConfig = { + ...oldConfig, + [key]: value, + } + + socketEmitPromise(socket, 'surfaces:config-set', [surfaceId, newConfig]) + .then((newConfig) => { + if (typeof newConfig === 'string') { + console.log('Config update failed', newConfig) + } else { + setSurfaceConfig(newConfig) + } + }) + .catch((e) => { + console.log('Config update failed', e) + }) + return newConfig + }) + } + }, + [socket, surfaceId] + ) + const setGroupConfigValue = useCallback( + (key: string, value: any) => { + console.log('update group', key, value) + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-set', [groupId, key, value]) + .then((newConfig) => { + if (typeof newConfig === 'string') { + console.log('group config update failed', newConfig) + } else { + setGroupConfig(newConfig) + } + }) + .catch((e) => { + console.log('group config update failed', e) + }) + + setGroupConfig((oldConfig) => { + return { + ...oldConfig, + [key]: value, + } + }) + } + }, + [socket, groupId] + ) + + const setSurfaceGroupId = useCallback( + (groupId0: string) => { + const groupId = !groupId0 || groupId0 === 'null' ? null : groupId0 + socketEmitPromise(socket, 'surfaces:add-to-group', [groupId, surfaceId]).catch((e) => { + console.log('Config update failed', e) + }) + }, + [socket, surfaceId] + ) + + const [modalRef, setModalRef] = useState(null) + + return ( + + + +
Settings for {surfaceInfo?.displayName ?? surfaceInfo?.type ?? groupInfo?.displayName}
+
+ + + + + {surfaceInfo && ( + + + Surface Group  + + + setSurfaceGroupId(e.currentTarget.value)} + > + + + {Object.values(surfacesContext) + .filter((group): group is ClientDevicesListItem => !!group && !group.isAutoGroup) + .map((group) => ( + + ))} + + + )} + + {groupConfig && ( + <> + + Use Last Page At Startup + setGroupConfigValue('use_last_page', !!e.currentTarget.checked)} + /> + + + Startup Page + + {InternalInstanceField( + PAGE_FIELD_SPEC, + false, + !!groupConfig.use_last_page, + groupConfig.startup_page, + (val) => setGroupConfigValue('startup_page', val) + )} + + + Current Page + + {InternalInstanceField(PAGE_FIELD_SPEC, false, false, groupConfig.last_page, (val) => + setGroupConfigValue('last_page', val) + )} + + + )} + + {surfaceConfig && surfaceInfo && ( + <> + {surfaceInfo.configFields?.includes('emulator_size') && ( + <> + + Row count + setSurfaceConfigValue('emulator_rows', parseInt(e.currentTarget.value))} + /> + + + Column count + setSurfaceConfigValue('emulator_columns', parseInt(e.currentTarget.value))} + /> + + + )} + + + Horizontal Offset in grid + setSurfaceConfigValue('xOffset', parseInt(e.currentTarget.value))} + /> + + + Vertical Offset in grid + setSurfaceConfigValue('yOffset', parseInt(e.currentTarget.value))} + /> + + + {surfaceInfo.configFields?.includes('brightness') && ( + + Brightness + setSurfaceConfigValue('brightness', parseInt(e.currentTarget.value))} + /> + + )} + {surfaceInfo.configFields?.includes('illuminate_pressed') && ( + + Illuminate pressed buttons + setSurfaceConfigValue('illuminate_pressed', !!e.currentTarget.checked)} + /> + + )} + + + Button rotation + { + const valueNumber = parseInt(e.currentTarget.value) + setSurfaceConfigValue('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) + }} + > + + + + + + {surfaceInfo.configFields?.includes('legacy_rotation') && ( + <> + + + + + )} + + + {surfaceInfo.configFields?.includes('emulator_control_enable') && ( + + Enable support for Logitech R400/Mastercue/DSan + setSurfaceConfigValue('emulator_control_enable', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( + + Prompt to enter fullscreen + setSurfaceConfigValue('emulator_prompt_fullscreen', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('videohub_page_count') && ( + + Page Count + setSurfaceConfigValue('videohub_page_count', parseInt(e.currentTarget.value))} + /> + + )} + + Never Pin code lock + setSurfaceConfigValue('never_lock', !!e.currentTarget.checked)} + /> + + + )} + + + + + Close + + +
+
+ ) + } +) diff --git a/webui/src/Surfaces/index.jsx b/webui/src/Surfaces/index.tsx similarity index 73% rename from webui/src/Surfaces/index.jsx rename to webui/src/Surfaces/index.tsx index a51472b73d..4e50e40d10 100644 --- a/webui/src/Surfaces/index.jsx +++ b/webui/src/Surfaces/index.tsx @@ -4,20 +4,19 @@ import { SurfacesContext, socketEmitPromise, SocketContext } from '../util' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAdd, faCog, faFolderOpen, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' import { TextInputField } from '../Components/TextInputField' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { SurfaceEditModal } from './EditModal' -import { AddSurfaceGroupModal } from './AddGroupModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' +import { SurfaceEditModal, SurfaceEditModalRef } from './EditModal' +import { AddSurfaceGroupModal, AddSurfaceGroupModalRef } from './AddGroupModal' import classNames from 'classnames' +import { ClientDevicesListItem, ClientSurfaceItem } from '@companion/shared/Model/Surfaces' export const SurfacesPage = memo(function SurfacesPage() { const socket = useContext(SocketContext) const surfacesContext = useContext(SurfacesContext) - const confirmRef = useRef(null) - - const editModalRef = useRef(null) - const addGroupModalRef = useRef(null) - const confirmModalRef = useRef(null) + const editModalRef = useRef(null) + const addGroupModalRef = useRef(null) + const confirmRef = useRef(null) const [scanning, setScanning] = useState(false) const [scanError, setScanError] = useState(null) @@ -56,7 +55,7 @@ export const SurfacesPage = memo(function SurfacesPage() { ) const addGroup = useCallback(() => { - addGroupModalRef.current.show() + addGroupModalRef.current?.show() }, [socket]) const deleteGroup = useCallback( @@ -70,17 +69,17 @@ export const SurfacesPage = memo(function SurfacesPage() { [socket] ) - const configureSurface = useCallback((surfaceId) => { - editModalRef.current.show(surfaceId, null) + const configureSurface = useCallback((surfaceId: string) => { + editModalRef.current?.show(surfaceId, null) }, []) - const configureGroup = useCallback((groupId) => { - editModalRef.current.show(null, groupId) + const configureGroup = useCallback((groupId: string) => { + editModalRef.current?.show(null, groupId) }, []) const forgetSurface = useCallback( (surfaceId) => { - confirmModalRef.current.show( + confirmRef.current?.show( 'Forget Surface', 'Are you sure you want to forget this surface? Any settings will be lost', 'Forget', @@ -103,12 +102,10 @@ export const SurfacesPage = memo(function SurfacesPage() { [socket] ) - const surfacesList = Object.values(surfacesContext).filter((grp) => !!grp) + const surfacesList = Object.values(surfacesContext).filter((grp): grp is ClientDevicesListItem => !!grp) return (
- -

Surfaces

These are the surfaces currently connected to companion. If your streamdeck is missing from this list, you might @@ -141,8 +138,8 @@ export const SurfacesPage = memo(function SurfacesPage() { + - @@ -156,30 +153,35 @@ export const SurfacesPage = memo(function SurfacesPage() { - {surfacesList.map((group) => - group.isAutoGroup && (group.surfaces || []).length === 1 ? ( - - ) : ( - - ) - )} + {surfacesList.map((group) => { + if (group.isAutoGroup && (group.surfaces || []).length === 1) { + return ( + + ) + } else { + return ( + + ) + } + })} {surfacesList.length === 0 && ( @@ -192,6 +194,15 @@ export const SurfacesPage = memo(function SurfacesPage() { ) }) +interface ManualGroupRowProps { + group: ClientDevicesListItem + configureGroup: (groupId: string) => void + deleteGroup: (groupId: string) => void + updateName: (surfaceId: string, name: string) => void + configureSurface: (surfaceId: string) => void + deleteEmulator: (surfaceId: string) => void + forgetSurface: (surfaceId: string) => void +} function ManualGroupRow({ group, configureGroup, @@ -200,7 +211,7 @@ function ManualGroupRow({ configureSurface, deleteEmulator, forgetSurface, -}) { +}: ManualGroupRowProps) { const configureGroup2 = useCallback(() => configureGroup(group.id), [configureGroup, group.id]) const deleteGroup2 = useCallback(() => deleteGroup(group.id), [deleteGroup, group.id]) const updateName2 = useCallback((val) => updateName(group.id, val), [updateName, group.id]) @@ -231,6 +242,7 @@ function ManualGroupRow({ void + configureSurface: (surfaceId: string) => void + deleteEmulator: (surfaceId: string) => void + forgetSurface: (surfaceId: string) => void + noBorder: boolean +} + +function SurfaceRow({ + surface, + index, + updateName, + configureSurface, + deleteEmulator, + forgetSurface, + noBorder, +}: SurfaceRowProps) { const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) const configureSurface2 = useCallback(() => configureSurface(surface.id), [configureSurface, surface.id]) const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) diff --git a/webui/src/TabletView/ButtonsFromPage.jsx b/webui/src/TabletView/ButtonsFromPage.tsx similarity index 70% rename from webui/src/TabletView/ButtonsFromPage.jsx rename to webui/src/TabletView/ButtonsFromPage.tsx index d44a5ce96b..a1e11fb837 100644 --- a/webui/src/TabletView/ButtonsFromPage.jsx +++ b/webui/src/TabletView/ButtonsFromPage.tsx @@ -1,11 +1,31 @@ -import { useCallback, useContext, useMemo } from 'react' +import React, { useCallback, useContext, useMemo } from 'react' import { SocketContext, socketEmitPromise } from '../util' import { ButtonPreview } from '../Components/ButtonPreview' import { useInView } from 'react-intersection-observer' import { formatLocation } from '@companion/shared/ControlId' import { useButtonRenderCache } from '../Hooks/useSharedRenderCache' +import type { UserConfigGridSize } from '@companion/shared/Model/UserConfigModel' +import { ControlLocation } from '@companion/shared/Model/Common' -export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSize, indexOffset }) { +export interface TabletGridSize extends UserConfigGridSize { + buttonCount: number +} + +interface ButtonsFromPageProps { + pageNumber: number + displayColumns: number + gridSize: TabletGridSize + buttonSize: number + indexOffset: number +} + +export function ButtonsFromPage({ + pageNumber, + displayColumns, + gridSize, + buttonSize, + indexOffset, +}: ButtonsFromPageProps) { const socket = useContext(SocketContext) const buttonClick = useCallback( @@ -65,7 +85,25 @@ export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSi ) } -function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, displayRow, buttonClick }) { + +interface ButtonWrapperProps { + pageNumber: number + column: number + row: number + buttonSize: number + displayColumn: number + displayRow: number + buttonClick: (location: ControlLocation, pressed: boolean) => void +} +function ButtonWrapper({ + pageNumber, + column, + row, + buttonSize, + displayColumn, + displayRow, + buttonClick, +}: ButtonWrapperProps) { const location = useMemo(() => ({ pageNumber, column, row }), [pageNumber, column, row]) const { image } = useButtonRenderCache(location) @@ -82,11 +120,10 @@ function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, dis return ( diff --git a/webui/src/TabletView/ConfigurePanel.jsx b/webui/src/TabletView/ConfigurePanel.tsx similarity index 93% rename from webui/src/TabletView/ConfigurePanel.jsx rename to webui/src/TabletView/ConfigurePanel.tsx index 2f3db16437..e1cfd7afed 100644 --- a/webui/src/TabletView/ConfigurePanel.jsx +++ b/webui/src/TabletView/ConfigurePanel.tsx @@ -1,10 +1,17 @@ -import { useState } from 'react' +import React, { useState } from 'react' import { PreventDefaultHandler, useMountEffect } from '../util' import { CButton, CCol, CForm, CFormGroup, CInput, CInputCheckbox, CRow } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCog, faExpand } from '@fortawesome/free-solid-svg-icons' +import type { UserConfigGridSize } from '@companion/shared/Model/UserConfigModel' -export function ConfigurePanel({ updateQueryUrl, query, gridSize }) { +interface ConfigurePanelProps { + updateQueryUrl: (key: string, value: any) => void + query: Record + gridSize: UserConfigGridSize +} + +export function ConfigurePanel({ updateQueryUrl, query, gridSize }: ConfigurePanelProps) { const [show, setShow] = useState(false) const [fullscreen, setFullscreen] = useState(document.fullscreenElement !== null) @@ -87,7 +94,6 @@ export function ConfigurePanel({ updateQueryUrl, query, gridSize }) { updateQueryUrl('noconfigure', !!e.currentTarget.checked)} /> @@ -96,7 +102,6 @@ export function ConfigurePanel({ updateQueryUrl, query, gridSize }) { updateQueryUrl('nofullscreen', !!e.currentTarget.checked)} /> @@ -106,7 +111,6 @@ export function ConfigurePanel({ updateQueryUrl, query, gridSize }) { updateQueryUrl('showpages', !!e.currentTarget.checked)} /> diff --git a/webui/src/TabletView/index.jsx b/webui/src/TabletView/index.tsx similarity index 76% rename from webui/src/TabletView/index.jsx rename to webui/src/TabletView/index.tsx index 2bb39c5103..0f3a953ed7 100644 --- a/webui/src/TabletView/index.jsx +++ b/webui/src/TabletView/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { LoadingRetryOrError, MyErrorBoundary, SocketContext } from '../util' import { CCol, CContainer, CRow } from '@coreui/react' import { nanoid } from 'nanoid' @@ -14,22 +14,34 @@ import { ButtonsFromPage } from './ButtonsFromPage' export function TabletView() { const socket = useContext(SocketContext) - const [loadError, setLoadError] = useState(null) + const [loadError, setLoadError] = useState(null) const [queryUrl, setQueryUrl] = useState(window.location.search) const { orderedPages, parsedQuery } = useMemo(() => { - const parsedQuery = queryString.parse(queryUrl) + const rawParsedQuery = queryString.parse(queryUrl) - const pagesRange = rangeParser(parsedQuery.pages ?? '').filter((p) => p >= 1 && p <= 99) + const pagesStr = Array.isArray(rawParsedQuery.pages) ? rawParsedQuery.pages[0] : rawParsedQuery.pages + const pagesRange = rangeParser(pagesStr ?? '').filter((p) => p >= 1 && p <= 99) - if (parsedQuery['max_col'] === undefined && parsedQuery['cols']) - parsedQuery['max_col'] = Number(parsedQuery['cols']) - 1 - if (parsedQuery['max_row'] === undefined && parsedQuery['rows']) - parsedQuery['max_row'] = Number(parsedQuery['rows']) - 1 + if (rawParsedQuery['max_col'] === undefined && rawParsedQuery['cols']) + rawParsedQuery['max_col'] = Number(rawParsedQuery['cols']) - 1 + '' + if (rawParsedQuery['max_row'] === undefined && rawParsedQuery['rows']) + rawParsedQuery['max_row'] = Number(rawParsedQuery['rows']) - 1 + '' // Remove renamed properties - delete parsedQuery['cols'] - delete parsedQuery['rows'] + delete rawParsedQuery['cols'] + delete rawParsedQuery['rows'] + + const parsedQuery: Record = {} + for (const [key, value] of Object.entries(rawParsedQuery)) { + if (Array.isArray(value)) { + if (value[0]) { + parsedQuery[key] = value[0] + } + } else if (value) { + parsedQuery[key] = value + } + } return { parsedQuery, @@ -62,7 +74,7 @@ export function TabletView() { if (value === '' || value === undefined || value === null || value === false) { delete newQuery[key] } else if (value === true) { - newQuery[key] = 1 + newQuery[key] = '1' } else { newQuery[key] = value } @@ -97,14 +109,24 @@ export function TabletView() { } const maxColumn = clampValue( - parsedQuery['max_col'], + Number(parsedQuery['max_col']), rawGridSize.minColumn, rawGridSize.maxColumn, rawGridSize.maxColumn ) - const minColumn = clampValue(parsedQuery['min_col'], rawGridSize.minColumn, maxColumn, rawGridSize.minColumn) - const maxRow = clampValue(parsedQuery['max_row'], rawGridSize.minRow, rawGridSize.maxRow, rawGridSize.maxRow) - const minRow = clampValue(parsedQuery['min_row'], rawGridSize.minRow, maxRow, rawGridSize.minRow) + const minColumn = clampValue( + Number(parsedQuery['min_col']), + rawGridSize.minColumn, + maxColumn, + rawGridSize.minColumn + ) + const maxRow = clampValue( + Number(parsedQuery['max_row']), + rawGridSize.minRow, + rawGridSize.maxRow, + rawGridSize.maxRow + ) + const minRow = clampValue(Number(parsedQuery['min_row']), rawGridSize.minRow, maxRow, rawGridSize.minRow) const columnCount = maxColumn - minColumn + 1 const rowCount = maxRow - minRow + 1 @@ -127,7 +149,7 @@ export function TabletView() { let displayColumns = Number(parsedQuery['display_cols']) if (displayColumns === 0 || isNaN(displayColumns)) displayColumns = gridSize.columnCount - const [elementSizeRef, pageSize] = useElementclientSize() + const [elementSizeRef, pageSize] = useElementclientSize() const buttonSize = pageSize.width / displayColumns const rowsPerPage = Math.ceil((gridSize.columnCount * gridSize.rowCount) / displayColumns) @@ -198,7 +220,7 @@ export function TabletView() { ) } -function clampValue(value, min, max, fallback) { +function clampValue(value: number, min: number, max: number, fallback: number): number { const valueNumber = Number(value) if (isNaN(valueNumber)) return fallback diff --git a/webui/src/Triggers/EditPanel.jsx b/webui/src/Triggers/EditPanel.tsx similarity index 74% rename from webui/src/Triggers/EditPanel.jsx rename to webui/src/Triggers/EditPanel.tsx index 1da5b71568..8023b97ced 100644 --- a/webui/src/Triggers/EditPanel.jsx +++ b/webui/src/Triggers/EditPanel.tsx @@ -1,31 +1,42 @@ import { CButton, CCol, CForm, CInputGroup, CInputGroupAppend, CLabel, CRow } from '@coreui/react' import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import { nanoid } from 'nanoid' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { LoadingRetryOrError, socketEmitPromise, SocketContext, MyErrorBoundary, PreventDefaultHandler } from '../util' -import { ControlActionSetEditor } from '../Controls/ActionSetEditor' -import jsonPatch from 'fast-json-patch' - -import { ControlOptionsEditor } from '../Controls/ControlOptionsEditor' -import { ControlFeedbacksEditor } from '../Controls/FeedbackEditor' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' +import { + LoadingRetryOrError, + socketEmitPromise, + SocketContext, + MyErrorBoundary, + PreventDefaultHandler, +} from '../util.js' +import { ControlActionSetEditor } from '../Controls/ActionSetEditor.jsx' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' + +import { ControlOptionsEditor } from '../Controls/ControlOptionsEditor.js' +import { ControlFeedbacksEditor } from '../Controls/FeedbackEditor.jsx' import { cloneDeep } from 'lodash-es' -import { TextInputField } from '../Components' -import { TriggerEventEditor } from './EventEditor' +import { TextInputField } from '../Components/index.js' +import { TriggerEventEditor } from './EventEditor.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' +import type { TriggerModel } from '@companion/shared/Model/TriggerModel.js' + +interface EditTriggerPanelProps { + controlId: string +} -export function EditTriggerPanel({ controlId }) { +export function EditTriggerPanel({ controlId }: EditTriggerPanelProps) { const socket = useContext(SocketContext) - const resetModalRef = useRef() + const resetModalRef = useRef(null) - const [config, setConfig] = useState(null) - const [runtimeProps, setRuntimeProps] = useState(null) + const [config, setConfig] = useState(null) + const [runtimeProps, setRuntimeProps] = useState | null>(null) - const configRef = useRef() - configRef.current = config // update the ref every render + const configRef = useRef() + configRef.current = config ?? undefined // update the ref every render - const [configError, setConfigError] = useState(null) + const [configError, setConfigError] = useState(null) const [reloadConfigToken, setReloadConfigToken] = useState(nanoid()) @@ -46,17 +57,17 @@ export function EditTriggerPanel({ controlId }) { setConfigError('Failed to load trigger config') }) - const patchConfig = (patch) => { + const patchConfig = (patch: JsonPatchOperation[] | false) => { setConfig((oldConfig) => { - if (patch === false) { - return false + if (!oldConfig || patch === false) { + return null } else { return jsonPatch.applyPatch(cloneDeep(oldConfig) || {}, patch).newDocument } }) } - const patchRuntimeProps = (patch) => { + const patchRuntimeProps = (patch: JsonPatchOperation[] | false) => { setRuntimeProps((oldProps) => { if (patch === false) { return {} @@ -85,11 +96,11 @@ export function EditTriggerPanel({ controlId }) { socketEmitPromise(socket, 'triggers:test', [controlId]).catch((e) => console.error(`Hot press failed: ${e}`)) }, [socket, controlId]) - const errors = [] + const errors: string[] = [] if (configError) errors.push(configError) const loadError = errors.length > 0 ? errors.join(', ') : null - const hasRuntimeProps = runtimeProps || runtimeProps === false - const dataReady = !loadError && config && hasRuntimeProps + const hasRuntimeProps = !!runtimeProps || runtimeProps === false + const dataReady = !loadError && !!config && hasRuntimeProps return (
@@ -153,6 +164,8 @@ export function EditTriggerPanel({ controlId }) { } controlId={controlId} + location={undefined} + stepId="" setId={'0'} addPlaceholder="+ Add action" actions={config.action_sets['0']} @@ -170,11 +183,17 @@ export function EditTriggerPanel({ controlId }) { ) } -function TriggerConfig({ controlId, options, hotPressDown }) { +interface TriggerConfigProps { + controlId: string + options: Record + hotPressDown: () => void +} + +function TriggerConfig({ controlId, options, hotPressDown }: TriggerConfigProps) { const socket = useContext(SocketContext) const setValueInner = useCallback( - (key, value) => { + (key: string, value: any) => { console.log('set', controlId, key, value) socketEmitPromise(socket, 'controls:set-options-field', [controlId, key, value]).catch((e) => { console.error(`Set field failed: ${e}`) @@ -183,7 +202,7 @@ function TriggerConfig({ controlId, options, hotPressDown }) { [socket, controlId] ) - const setName = useCallback((val) => setValueInner('name', val), [setValueInner]) + const setName = useCallback((val: string) => setValueInner('name', val), [setValueInner]) return ( diff --git a/webui/src/Triggers/EventEditor.jsx b/webui/src/Triggers/EventEditor.tsx similarity index 74% rename from webui/src/Triggers/EventEditor.jsx rename to webui/src/Triggers/EventEditor.tsx index 29585b20b9..960e4e766e 100644 --- a/webui/src/Triggers/EventEditor.jsx +++ b/webui/src/Triggers/EventEditor.tsx @@ -1,32 +1,40 @@ import { CButton, CForm, CButtonGroup, CSwitch } from '@coreui/react' import { faSort, faTrash, faCompressArrowsAlt, faExpandArrowsAlt, faCopy } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { FormEvent, memo, useCallback, useContext, useMemo, useRef } from 'react' import { MyErrorBoundary, socketEmitPromise, - sandbox, SocketContext, EventDefinitionsContext, PreventDefaultHandler, -} from '../util' +} from '../util.js' import Select from 'react-select' -import { OptionsInputField } from '../Controls/OptionsInputField' +import { OptionsInputField } from '../Controls/OptionsInputField.js' import { useDrag, useDrop } from 'react-dnd' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { usePanelCollapseHelper } from '../Helpers/CollapseHelper' -import { MenuPortalContext } from '../Components/DropdownInputField' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' +import { usePanelCollapseHelper } from '../Helpers/CollapseHelper.js' +import { MenuPortalContext } from '../Components/DropdownInputField.js' +import type { DropdownChoice, DropdownChoiceId } from '@companion-module/base' +import type { EventInstance } from '@companion/shared/Model/EventModel.js' +import { useOptionsAndIsVisible } from '../Hooks/useOptionsAndIsVisible.js' + +interface TriggerEventEditorProps { + controlId: string + events: EventInstance[] + heading: JSX.Element | string +} -export function TriggerEventEditor({ controlId, events, heading }) { +export function TriggerEventEditor({ controlId, events, heading }: TriggerEventEditorProps) { const socket = useContext(SocketContext) - const confirmModal = useRef() + const confirmModal = useRef(null) - const eventsRef = useRef() + const eventsRef = useRef() eventsRef.current = events const setValue = useCallback( - (eventId, key, val) => { + (eventId: string, key: string, val: any) => { const currentEvent = eventsRef.current?.find((fb) => fb.id === eventId) if (!currentEvent?.options || currentEvent.options[key] !== val) { socketEmitPromise(socket, 'controls:event:set-option', [controlId, eventId, key, val]).catch((e) => { @@ -38,8 +46,8 @@ export function TriggerEventEditor({ controlId, events, heading }) { ) const doDelete = useCallback( - (eventId) => { - confirmModal.current.show('Delete event', 'Delete event?', 'Delete', () => { + (eventId: string) => { + confirmModal.current?.show('Delete event', 'Delete event?', 'Delete', () => { socketEmitPromise(socket, 'controls:event:remove', [controlId, eventId]).catch((e) => { console.error(`Failed to delete event: ${e}`) }) @@ -49,7 +57,7 @@ export function TriggerEventEditor({ controlId, events, heading }) { ) const doDuplicate = useCallback( - (eventId) => { + (eventId: string) => { socketEmitPromise(socket, 'controls:event:duplicate', [controlId, eventId]).catch((e) => { console.error(`Failed to duplicate feeeventdback: ${e}`) }) @@ -58,7 +66,7 @@ export function TriggerEventEditor({ controlId, events, heading }) { ) const addEvent = useCallback( - (eventType) => { + (eventType: DropdownChoiceId) => { socketEmitPromise(socket, 'controls:event:add', [controlId, eventType]).catch((e) => { console.error('Failed to add trigger event', e) }) @@ -67,7 +75,7 @@ export function TriggerEventEditor({ controlId, events, heading }) { ) const moveCard = useCallback( - (dragIndex, hoverIndex) => { + (dragIndex: number, hoverIndex: number) => { socketEmitPromise(socket, 'controls:event:reorder', [controlId, dragIndex, hoverIndex]).catch((e) => { console.error(`Move failed: ${e}`) }) @@ -76,7 +84,7 @@ export function TriggerEventEditor({ controlId, events, heading }) { ) const emitEnabled = useCallback( - (eventId, enabled) => { + (eventId: string, enabled: boolean) => { socketEmitPromise(socket, 'controls:event:enabled', [controlId, eventId, enabled]).catch((e) => { console.error('Failed to enable/disable event', e) }) @@ -119,7 +127,6 @@ export function TriggerEventEditor({ controlId, events, heading }) { void + setValue: (eventId: string, key: string, value: any) => void + doDelete: (eventId: string) => void + doDuplicate: (eventId: string) => void + doEnabled: (eventId: string, value: boolean) => void + isCollapsed: boolean + setCollapsed: (eventId: string, collapsed: boolean) => void +} + function EventsTableRow({ event, - controlId, index, dragId, moveCard, @@ -154,14 +180,14 @@ function EventsTableRow({ doEnabled, isCollapsed, setCollapsed, -}) { +}: EventsTableRowProps): JSX.Element | null { const innerDelete = useCallback(() => doDelete(event.id), [event.id, doDelete]) const innerDuplicate = useCallback(() => doDuplicate(event.id), [event.id, doDuplicate]) - const ref = useRef(null) - const [, drop] = useDrop({ + const ref = useRef(null) + const [, drop] = useDrop({ accept: dragId, - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -182,10 +208,9 @@ function EventsTableRow({ item.index = hoverIndex }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: dragId, item: { - actionId: event.id, index: index, }, collect: (monitor) => ({ @@ -203,7 +228,7 @@ function EventsTableRow({ if (!event) { // Invalid event, so skip - return '' + return null } return ( @@ -213,7 +238,6 @@ function EventsTableRow({
{triggersList && Object.keys(triggersList).length > 0 ? ( Object.entries(triggersList) + .filter((o): o is [string, ClientTriggerData] => !!o[1]) .sort((a, b) => a[1].sortOrder - b[1].sortOrder) .map(([controlId, item]) => ( - @@ -214,10 +223,26 @@ function TriggersTable({ triggersList, editItem, selectedControlId }) {
void + innerDelete: () => void + innerDuplicate: () => void + isCollapsed: boolean + doCollapse: () => void + doExpand: () => void + doEnabled: (eventId: string, value: boolean) => void +} + function EventEditor({ event, - controlId, setValue, innerDelete, innerDuplicate, @@ -238,53 +272,19 @@ function EventEditor({ doCollapse, doExpand, doEnabled, -}) { +}: EventEditorProps) { const EventDefinitions = useContext(EventDefinitionsContext) const eventSpec = EventDefinitions[event.type] - const options = eventSpec?.options ?? [] - - const [optionVisibility, setOptionVisibility] = useState({}) - - const innerSetEnabled = useCallback((e) => doEnabled(event.id, e.target.checked), [doEnabled, event.id]) - useEffect(() => { - const options = eventSpec?.options ?? [] - - for (const option of options) { - if (typeof option.isVisibleFn === 'string') { - option.isVisible = sandbox(option.isVisibleFn) - } - } - }, [eventSpec]) + const [eventOptions, optionVisibility] = useOptionsAndIsVisible(eventSpec, event) - useEffect(() => { - const visibility = {} - const options = eventSpec?.options ?? [] - - if (options === null || event === null) { - return - } - - for (const option of options) { - if (typeof option.isVisible === 'function') { - visibility[option.id] = option.isVisible(event.options, option.isVisibleData) - } - } - - setOptionVisibility(visibility) - - return () => { - setOptionVisibility({}) - } - }, [eventSpec, event]) + const innerSetEnabled = useCallback( + (e: FormEvent) => doEnabled(event.id, e.currentTarget.checked), + [doEnabled, event.id] + ) - let name = '' - if (eventSpec) { - name = eventSpec.name - } else { - name = `${event.type} (undefined)` - } + const name = eventSpec ? eventSpec.name : `${event.type} (undefined)` return ( <> @@ -308,7 +308,7 @@ function EventEditor({ - {doEnabled && ( + {!!doEnabled && ( <>   - {options.map((opt, i) => ( + {eventOptions.map((opt, i) => ( { +const noOptionsMessage = ({}) => { return 'No events found' } -const AddEventDropdown = memo(function AddEventDropdown({ onSelect }) { +interface AddEventDropdownProps { + onSelect: (value: DropdownChoiceId) => void +} + +const AddEventDropdown = memo(function AddEventDropdown({ onSelect }: AddEventDropdownProps) { const menuPortal = useContext(MenuPortalContext) const EventDefinitions = useContext(EventDefinitionsContext) const options = useMemo(() => { - const options = [] + const options: DropdownChoice[] = [] for (const [eventId, event] of Object.entries(EventDefinitions || {})) { + if (!event) continue options.push({ - value: eventId, + id: eventId, label: event.name, }) } @@ -375,9 +381,9 @@ const AddEventDropdown = memo(function AddEventDropdown({ onSelect }) { }, [EventDefinitions]) const innerChange = useCallback( - (e) => { - if (e.value) { - onSelect(e.value) + (e: DropdownChoice | null) => { + if (e?.id) { + onSelect(e.id) } }, [onSelect] diff --git a/webui/src/Triggers/index.jsx b/webui/src/Triggers/index.tsx similarity index 83% rename from webui/src/Triggers/index.jsx rename to webui/src/Triggers/index.tsx index e568745377..602b9a8e6f 100644 --- a/webui/src/Triggers/index.jsx +++ b/webui/src/Triggers/index.tsx @@ -28,24 +28,25 @@ import { import { useDrag, useDrop } from 'react-dnd' import { nanoid } from 'nanoid' import { EditTriggerPanel } from './EditPanel' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' import { ParseControlId } from '@companion/shared/ControlId' -import { ConfirmExportModal } from '../Components/ConfirmExportModal' +import { ConfirmExportModal, ConfirmExportModalRef } from '../Components/ConfirmExportModal' import classNames from 'classnames' +import { ClientTriggerData } from '@companion/shared/Model/TriggerModel' export const Triggers = memo(function Triggers() { const socket = useContext(SocketContext) const triggersList = useContext(TriggersContext) - const [editItemId, setEditItemId] = useState(null) + const [editItemId, setEditItemId] = useState(null) const [tabResetToken, setTabResetToken] = useState(nanoid()) const [activeTab, setActiveTab] = useState('placeholder') // Ensure the selected trigger is valid useEffect(() => { setEditItemId((currentId) => { - if (triggersList[currentId]) { + if (currentId && triggersList[currentId]) { return currentId } else { return null @@ -53,7 +54,7 @@ export const Triggers = memo(function Triggers() { }) }, [triggersList]) - const doChangeTab = useCallback((newTab) => { + const doChangeTab = useCallback((newTab: string) => { setActiveTab((oldTab) => { const preserveButtonsTab = newTab === 'variables' && oldTab === 'edit' if (newTab !== 'edit' && oldTab !== newTab && !preserveButtonsTab) { @@ -63,7 +64,7 @@ export const Triggers = memo(function Triggers() { return newTab }) }, []) - const doEditItem = useCallback((controlId) => { + const doEditItem = useCallback((controlId: string) => { setEditItemId(controlId) setActiveTab('edit') }, []) @@ -79,9 +80,9 @@ export const Triggers = memo(function Triggers() { }) }, [socket, doEditItem]) - const exportModalRef = useRef(null) + const exportModalRef = useRef(null) const showExportModal = useCallback(() => { - exportModalRef.current.show(`/int/export/triggers/all`) + exportModalRef.current?.show(`/int/export/triggers/all`) }, []) return ( @@ -145,8 +146,14 @@ export const Triggers = memo(function Triggers() { ) }) +interface TriggersTableProps { + triggersList: Record + editItem: (controlId: string) => void + selectedControlId: string | null +} + const tableDateFormat = 'MM/DD HH:mm:ss' -function TriggersTable({ triggersList, editItem, selectedControlId }) { +function TriggersTable({ triggersList, editItem, selectedControlId }: TriggersTableProps) { const socket = useContext(SocketContext) const triggersRef = useRef(triggersList) @@ -161,6 +168,7 @@ function TriggersTable({ triggersList, editItem, selectedControlId }) { if (triggersRef.current) { const rawIds = Object.entries(triggersRef.current) + .filter((o): o is [string, ClientTriggerData] => !!o[1]) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) .map(([id]) => id) @@ -192,6 +200,7 @@ function TriggersTable({ triggersList, editItem, selectedControlId }) {
+ There currently are no triggers or scheduled tasks.
) } -function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected }) { + +interface TriggersTableRowDragData { + id: string +} +interface TriggersTableRowDragStatus { + isDragging: boolean +} + +interface TriggersTableRowProps { + controlId: string + item: ClientTriggerData + editItem: (controlId: string) => void + moveTrigger: (hoverControlId: string, controlId: string) => void + isSelected: boolean +} + +function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected }: TriggersTableRowProps) { const socket = useContext(SocketContext) - const confirmRef = useRef(null) + const confirmRef = useRef(null) const doEnableDisable = useCallback(() => { socketEmitPromise(socket, 'controls:set-options-field', [controlId, 'enabled', !item.enabled]).catch((e) => { @@ -225,7 +250,7 @@ function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected } }) }, [socket, controlId, item.enabled]) const doDelete = useCallback(() => { - confirmRef.current.show('Delete trigger', 'Are you sure you wish to delete this trigger?', 'Delete', () => { + confirmRef.current?.show('Delete trigger', 'Are you sure you wish to delete this trigger?', 'Delete', () => { socketEmitPromise(socket, 'triggers:delete', [controlId]).catch((e) => { console.error('Failed to delete', e) }) @@ -253,9 +278,9 @@ function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected } ) const ref = useRef(null) - const [, drop] = useDrop({ + const [, drop] = useDrop({ accept: 'trigger', - hover(hoverItem, monitor) { + hover(hoverItem, _monitor) { if (!ref.current) { return } @@ -268,7 +293,7 @@ function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected } moveTrigger(hoverItem.id, controlId) }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: 'trigger', item: { id: controlId, @@ -294,12 +319,7 @@ function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected }

{ - doEdit() - }} - className="hand" - > + {item.name} {/* TODO: For some reason, the modal component leaves a big inline @@ -309,12 +329,7 @@ function TriggersTableRow({ controlId, item, editItem, moveTrigger, isSelected } {/* end hax */} { - doEdit() - }} - className="hand" - > +
{item.lastExecuted ? Last run: {dayjs(item.lastExecuted).format(tableDateFormat)} : ''} diff --git a/webui/src/UserConfig/AdminPasswordConfig.jsx b/webui/src/UserConfig/AdminPasswordConfig.tsx similarity index 85% rename from webui/src/UserConfig/AdminPasswordConfig.jsx rename to webui/src/UserConfig/AdminPasswordConfig.tsx index 53634bcb1c..751b2d4dfc 100644 --- a/webui/src/UserConfig/AdminPasswordConfig.jsx +++ b/webui/src/UserConfig/AdminPasswordConfig.tsx @@ -3,17 +3,24 @@ import { CAlert, CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel.js' -export function AdminPasswordConfig({ config, setValue, resetValue }) { +interface AdminPasswordConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function AdminPasswordConfig({ config, setValue, resetValue }: AdminPasswordConfigProps) { return ( <>
+ Admin UI Password
+ This does not make an installation secure!
This is intended to keep normal users from stumbling upon the settings and changing things. It will diff --git a/webui/src/UserConfig/ArtnetConfig.jsx b/webui/src/UserConfig/ArtnetConfig.tsx similarity index 81% rename from webui/src/UserConfig/ArtnetConfig.jsx rename to webui/src/UserConfig/ArtnetConfig.tsx index e65d4ff5f9..f26509dd23 100644 --- a/webui/src/UserConfig/ArtnetConfig.jsx +++ b/webui/src/UserConfig/ArtnetConfig.tsx @@ -3,12 +3,19 @@ import { CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function ArtnetConfig({ config, setValue, resetValue }) { +interface ArtnetConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function ArtnetConfig({ config, setValue, resetValue }: ArtnetConfigProps) { return ( <>
+ Artnet Listener
+ Buttons
+ Ember+
+ Experiments
+ Do not touch these settings unless you know what you are doing!
+ Grid
+ HTTP
+ HTTPS Web Server
+

An HTTPS server can be enabled for the Companion web interfaces should your deployment require it.

It is never recommended to expose the Companion interface to the Internet and HTTPS does not provide any @@ -98,11 +105,11 @@ export function HttpsConfig({ config, setValue, resetValue }) { {config.https_cert_type === 'self' && (
+ - + @@ -185,11 +192,11 @@ export function HttpsConfig({ config, setValue, resetValue }) { {config.https_cert_type === 'external' && ( -
This tool will help create a self-signed certificate for the server to use.This tool will help create a self-signed certificate for the server to use.
+ - - diff --git a/webui/src/UserConfig/OscProtocol.jsx b/webui/src/UserConfig/OscProtocol.tsx similarity index 96% rename from webui/src/UserConfig/OscProtocol.jsx rename to webui/src/UserConfig/OscProtocol.tsx index 5c1f44d673..b0d6c38ca6 100644 --- a/webui/src/UserConfig/OscProtocol.jsx +++ b/webui/src/UserConfig/OscProtocol.tsx @@ -8,12 +8,7 @@ export function OscProtocol() { <>

Remote triggering can be done by sending OSC commands to port{' '} - - {config?.osc_enabled && config?.osc_listen_port && config?.osc_listen_port !== '0' - ? config?.osc_listen_port - : 'disabled'} - - . + {config?.osc_enabled && config?.osc_listen_port ? config?.osc_listen_port : 'disabled'}.

Commands: diff --git a/webui/src/UserConfig/PinLockoutConfig.jsx b/webui/src/UserConfig/PinLockoutConfig.tsx similarity index 86% rename from webui/src/UserConfig/PinLockoutConfig.jsx rename to webui/src/UserConfig/PinLockoutConfig.tsx index 6563e9c7bf..714e1ece21 100644 --- a/webui/src/UserConfig/PinLockoutConfig.jsx +++ b/webui/src/UserConfig/PinLockoutConfig.tsx @@ -3,12 +3,19 @@ import { CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function PinLockoutConfig({ config, setValue, resetValue }) { +interface PinLockoutConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function PinLockoutConfig({ config, setValue, resetValue }: PinLockoutConfigProps) { return ( <>

- diff --git a/webui/src/UserConfig/RosstalkConfig.jsx b/webui/src/UserConfig/RosstalkConfig.tsx similarity index 69% rename from webui/src/UserConfig/RosstalkConfig.jsx rename to webui/src/UserConfig/RosstalkConfig.tsx index 0d8d980544..b37d6824f3 100644 --- a/webui/src/UserConfig/RosstalkConfig.jsx +++ b/webui/src/UserConfig/RosstalkConfig.tsx @@ -3,12 +3,19 @@ import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function RosstalkConfig({ config, setValue, resetValue }) { +interface RosstalkConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function RosstalkConfig({ config, setValue, resetValue }: RosstalkConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/RosstalkProtocol.jsx b/webui/src/UserConfig/RosstalkProtocol.tsx similarity index 100% rename from webui/src/UserConfig/RosstalkProtocol.jsx rename to webui/src/UserConfig/RosstalkProtocol.tsx diff --git a/webui/src/UserConfig/SatelliteConfig.jsx b/webui/src/UserConfig/SatelliteConfig.jsx deleted file mode 100644 index 6b66db6970..0000000000 --- a/webui/src/UserConfig/SatelliteConfig.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' - -export function SatelliteConfig({ config, setValue, resetValue }) { - return ( - <> - - - - - - - - - - ) -} diff --git a/webui/src/UserConfig/SatelliteConfig.tsx b/webui/src/UserConfig/SatelliteConfig.tsx new file mode 100644 index 0000000000..d8dbc126c0 --- /dev/null +++ b/webui/src/UserConfig/SatelliteConfig.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' + +interface SatelliteConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function SatelliteConfig({}: SatelliteConfigProps) { + return ( + <> + + + + + + + + + + ) +} diff --git a/webui/src/UserConfig/SurfacesConfig.jsx b/webui/src/UserConfig/SurfacesConfig.tsx similarity index 88% rename from webui/src/UserConfig/SurfacesConfig.jsx rename to webui/src/UserConfig/SurfacesConfig.tsx index 5b8997837c..f4fbd5c48e 100644 --- a/webui/src/UserConfig/SurfacesConfig.jsx +++ b/webui/src/UserConfig/SurfacesConfig.tsx @@ -3,12 +3,19 @@ import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function SurfacesConfig({ config, setValue, resetValue }) { +interface SurfacesConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function SurfacesConfig({ config, setValue, resetValue }: SurfacesConfigProps) { return ( <> - @@ -49,7 +56,7 @@ export function SurfacesConfig({ config, setValue, resetValue }) { diff --git a/webui/src/UserConfig/TcpConfig.jsx b/webui/src/UserConfig/TcpConfig.tsx similarity index 83% rename from webui/src/UserConfig/TcpConfig.jsx rename to webui/src/UserConfig/TcpConfig.tsx index 0a222bb5bb..47a687277d 100644 --- a/webui/src/UserConfig/TcpConfig.jsx +++ b/webui/src/UserConfig/TcpConfig.tsx @@ -3,12 +3,19 @@ import { CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function TcpConfig({ config, setValue, resetValue }) { +interface TcpConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function TcpConfig({ config, setValue, resetValue }: TcpConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/TcpUdpProtocol.jsx b/webui/src/UserConfig/TcpUdpProtocol.tsx similarity index 94% rename from webui/src/UserConfig/TcpUdpProtocol.jsx rename to webui/src/UserConfig/TcpUdpProtocol.tsx index 50163427a1..43c168258f 100644 --- a/webui/src/UserConfig/TcpUdpProtocol.jsx +++ b/webui/src/UserConfig/TcpUdpProtocol.tsx @@ -4,14 +4,8 @@ import { UserConfigContext } from '../util' export function TcpUdpProtocol() { const config = useContext(UserConfigContext) - const tcpPort = - config?.tcp_enabled && config?.tcp_listen_port && config?.tcp_listen_port !== '0' - ? config?.tcp_listen_port - : 'disabled' - const udpPort = - config?.udp_enabled && config?.udp_listen_port && config?.udp_listen_port !== '0' - ? config?.udp_listen_port - : 'disabled' + const tcpPort = config?.tcp_enabled && config?.tcp_listen_port ? config?.tcp_listen_port : 'disabled' + const udpPort = config?.udp_enabled && config?.udp_listen_port ? config?.udp_listen_port : 'disabled' return ( <> diff --git a/webui/src/UserConfig/UdpConfig.jsx b/webui/src/UserConfig/UdpConfig.tsx similarity index 83% rename from webui/src/UserConfig/UdpConfig.jsx rename to webui/src/UserConfig/UdpConfig.tsx index 7c82612037..65b6d413b9 100644 --- a/webui/src/UserConfig/UdpConfig.jsx +++ b/webui/src/UserConfig/UdpConfig.tsx @@ -3,12 +3,19 @@ import { CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function UdpConfig({ config, setValue, resetValue }) { +interface UdpConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function UdpConfig({ config, setValue, resetValue }: UdpConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/VideohubServerConfig.jsx b/webui/src/UserConfig/VideohubServerConfig.tsx similarity index 68% rename from webui/src/UserConfig/VideohubServerConfig.jsx rename to webui/src/UserConfig/VideohubServerConfig.tsx index 19173d9faf..79cd434885 100644 --- a/webui/src/UserConfig/VideohubServerConfig.jsx +++ b/webui/src/UserConfig/VideohubServerConfig.tsx @@ -1,14 +1,21 @@ import React from 'react' -import { CButton, CInput } from '@coreui/react' +import { CButton } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function VideohubServerConfig({ config, setValue, resetValue }) { +interface VideohubServerConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function VideohubServerConfig({ config, setValue, resetValue }: VideohubServerConfigProps) { return ( <> - diff --git a/webui/src/UserConfig/index.jsx b/webui/src/UserConfig/index.tsx similarity index 99% rename from webui/src/UserConfig/index.jsx rename to webui/src/UserConfig/index.tsx index 981c5bf268..f12ec62645 100644 --- a/webui/src/UserConfig/index.jsx +++ b/webui/src/UserConfig/index.tsx @@ -65,6 +65,8 @@ function UserConfigTable() { [socket] ) + if (!config) return null + return (
+

This requires you to generate your own self-signed certificate or go through a certificate authority. A properly signed certificate will work. diff --git a/webui/src/UserConfig/OscConfig.jsx b/webui/src/UserConfig/OscConfig.tsx similarity index 83% rename from webui/src/UserConfig/OscConfig.jsx rename to webui/src/UserConfig/OscConfig.tsx index e1cb3ad39a..548d8e381e 100644 --- a/webui/src/UserConfig/OscConfig.jsx +++ b/webui/src/UserConfig/OscConfig.tsx @@ -3,12 +3,19 @@ import { CButton, CInput } from '@coreui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUndo } from '@fortawesome/free-solid-svg-icons' import CSwitch from '../CSwitch' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function OscConfig({ config, setValue, resetValue }) { +interface OscConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function OscConfig({ config, setValue, resetValue }: OscConfigProps) { return ( <>

+ OSC
+ PIN Lockout
+ RossTalk
- Satellite -
Satellite Listen Port16622
+ Satellite +
Satellite Listen Port16622
+ Surfaces
- resetValue('elegato_plugin_enable')} title="Reset to default"> + resetValue('elgato_plugin_enable')} title="Reset to default">
+ TCP
+ UDP
+ Videohub Panel
diff --git a/webui/src/Wizard/ApplyStep.jsx b/webui/src/Wizard/ApplyStep.tsx similarity index 91% rename from webui/src/Wizard/ApplyStep.jsx rename to webui/src/Wizard/ApplyStep.tsx index c2429a55c4..9b00a8cb98 100644 --- a/webui/src/Wizard/ApplyStep.jsx +++ b/webui/src/Wizard/ApplyStep.tsx @@ -1,7 +1,13 @@ import React from 'react' import { WIZARD_VERSION_3_0 } from '.' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function ApplyStep({ oldConfig, newConfig }) { +interface ApplyStepProps { + oldConfig: UserConfigModel + newConfig: UserConfigModel +} + +export function ApplyStep({ oldConfig, newConfig }: ApplyStepProps) { let changes = [] if (oldConfig.setup_wizard < WIZARD_VERSION_3_0 || oldConfig.usb_hotplug !== newConfig.usb_hotplug) { @@ -216,18 +222,17 @@ export function ApplyStep({ oldConfig, newConfig }) { (oldConfig.setup_wizard === 0 && newConfig.admin_lockout) || (newConfig.admin_lockout && oldConfig.admin_timeout !== newConfig.admin_timeout) ) { + const oldAdminTimeoutStr = oldConfig.admin_timeout + '' + const newAdminTimeoutStr = newConfig.admin_timeout + '' oldConfig.setup_wizard > 0 ? changes.push(
  • - Change admin GUI timeout from{' '} - {oldConfig.admin_timeout === '0' ? 'none' : oldConfig.admin_timeout + ' minutes'} to{' '} - {newConfig.admin_timeout === '0' ? 'none' : newConfig.admin_timeout + ' minutes'}. + Change admin GUI timeout from {oldAdminTimeoutStr === '0' ? 'none' : oldConfig.admin_timeout + ' minutes'}{' '} + to {newAdminTimeoutStr ? 'none' : newConfig.admin_timeout + ' minutes'}.
  • ) : changes.push( -
  • - Set admin GUI timeout to {newConfig.admin_timeout === '0' ? 'none' : newConfig.admin_timeout + ' minutes'}. -
  • +
  • Set admin GUI timeout to {newAdminTimeoutStr ? 'none' : newConfig.admin_timeout + ' minutes'}.
  • ) } diff --git a/webui/src/Wizard/BeginStep.jsx b/webui/src/Wizard/BeginStep.tsx similarity index 100% rename from webui/src/Wizard/BeginStep.jsx rename to webui/src/Wizard/BeginStep.tsx diff --git a/webui/src/Wizard/FinishStep.jsx b/webui/src/Wizard/FinishStep.tsx similarity index 86% rename from webui/src/Wizard/FinishStep.jsx rename to webui/src/Wizard/FinishStep.tsx index cfccd96f51..186e50aef0 100644 --- a/webui/src/Wizard/FinishStep.jsx +++ b/webui/src/Wizard/FinishStep.tsx @@ -1,7 +1,13 @@ import React from 'react' import { CAlert } from '@coreui/react' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function FinishStep({ oldConfig, newConfig }) { +interface FinishStepProps { + oldConfig: UserConfigModel + newConfig: UserConfigModel +} + +export function FinishStep({ oldConfig, newConfig }: FinishStepProps) { return (

    Congratulations!

    diff --git a/webui/src/Wizard/PasswordStep.jsx b/webui/src/Wizard/PasswordStep.tsx similarity index 85% rename from webui/src/Wizard/PasswordStep.jsx rename to webui/src/Wizard/PasswordStep.tsx index ecc955cc46..7231003c53 100644 --- a/webui/src/Wizard/PasswordStep.jsx +++ b/webui/src/Wizard/PasswordStep.tsx @@ -1,7 +1,13 @@ import React from 'react' import { CAlert, CInput, CInputCheckbox, CLabel } from '@coreui/react' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function PasswordStep({ config, setValue }) { +interface PasswordStepProps { + config: Partial + setValue: (key: keyof UserConfigModel, value: any) => void +} + +export function PasswordStep({ config, setValue }: PasswordStepProps) { return (
    Admin GUI Password
    diff --git a/webui/src/Wizard/ServicesStep.jsx b/webui/src/Wizard/ServicesStep.tsx similarity index 94% rename from webui/src/Wizard/ServicesStep.jsx rename to webui/src/Wizard/ServicesStep.tsx index af7f714c6f..fd84761c98 100644 --- a/webui/src/Wizard/ServicesStep.jsx +++ b/webui/src/Wizard/ServicesStep.tsx @@ -1,7 +1,13 @@ import React from 'react' import { CInput, CInputCheckbox, CLabel } from '@coreui/react' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function ServicesStep({ config, setValue }) { +interface ServicesStepProps { + config: Partial + setValue: (key: keyof UserConfigModel, value: any) => void +} + +export function ServicesStep({ config, setValue }: ServicesStepProps) { return (
    Remote Control Services
    diff --git a/webui/src/Wizard/SurfacesStep.jsx b/webui/src/Wizard/SurfacesStep.tsx similarity index 86% rename from webui/src/Wizard/SurfacesStep.jsx rename to webui/src/Wizard/SurfacesStep.tsx index 7ea9ded679..71d35d771a 100644 --- a/webui/src/Wizard/SurfacesStep.jsx +++ b/webui/src/Wizard/SurfacesStep.tsx @@ -1,7 +1,13 @@ import React from 'react' import { CInputCheckbox, CInputRadio, CLabel } from '@coreui/react' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel' -export function SurfacesStep({ config, setValue }) { +interface SurfacesStepProps { + config: Partial + setValue: (key: keyof UserConfigModel, value: any) => void +} + +export function SurfacesStep({ config, setValue }: SurfacesStepProps) { return (
    USB Surface Detection Configuration
    @@ -22,7 +28,7 @@ export function SurfacesStep({ config, setValue }) { setValue('elgato_plugin_enable', false)} + onChange={() => setValue('elgato_plugin_enable', false)} /> Use Companion natively (requires Stream Deck software to be closed) @@ -34,7 +40,7 @@ export function SurfacesStep({ config, setValue }) { setValue('elgato_plugin_enable', true)} + onChange={() => setValue('elgato_plugin_enable', true)} /> Use Stream Deck software via Companion plugin
    diff --git a/webui/src/Wizard/index.jsx b/webui/src/Wizard/index.tsx similarity index 60% rename from webui/src/Wizard/index.jsx rename to webui/src/Wizard/index.tsx index 73199437c1..884f2bc035 100644 --- a/webui/src/Wizard/index.jsx +++ b/webui/src/Wizard/index.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' +import React, { FormEvent, forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' import { CAlert, CButton, CForm, CModal, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' import { SocketContext, socketEmitPromise } from '../util' import { BeginStep } from './BeginStep' @@ -7,21 +7,29 @@ import { ServicesStep } from './ServicesStep' import { PasswordStep } from './PasswordStep' import { ApplyStep } from './ApplyStep' import { FinishStep } from './FinishStep' +import { UserConfigModel } from '@companion/shared/Model/UserConfigModel' export const WIZARD_VERSION_2_2 = 22 // 2.2 export const WIZARD_VERSION_3_0 = 30 // 3.0 export const WIZARD_CURRENT_VERSION = WIZARD_VERSION_3_0 -export const WizardModal = forwardRef(function WizardModal(_props, ref) { +export interface WizardModalRef { + show(): void +} +interface WizardModalProps { + // Nothing +} + +export const WizardModal = forwardRef(function WizardModal(_props, ref) { const socket = useContext(SocketContext) const [currentStep, setCurrentStep] = useState(1) const maxSteps = 6 // can use useState in the future if the number of steps needs to be dynamic const applyStep = 5 // can use useState in the future if the number of steps needs to be dynamic const [startConfig, setStartConfig] = useState(null) - const [oldConfig, setOldConfig] = useState(null) - const [newConfig, setNewConfig] = useState(null) - const [error, setError] = useState(null) + const [oldConfig, setOldConfig] = useState(null) + const [newConfig, setNewConfig] = useState(null) + const [error, setError] = useState(null) const [clear, setClear] = useState(true) const getConfig = useCallback(() => { @@ -46,37 +54,38 @@ export const WizardModal = forwardRef(function WizardModal(_props, ref) { }, [socket]) const doNextStep = useCallback(() => { - let newStep = currentStep - // Make sure step is set to something reasonable - if (newStep >= maxSteps - 1) { - newStep = maxSteps - } else { - newStep = newStep + 1 - } - - setCurrentStep(newStep) - }, [currentStep, maxSteps]) + setCurrentStep((currentStep) => { + // Make sure step is set to something reasonable + if (currentStep >= maxSteps - 1) { + return maxSteps + } else { + return currentStep + 1 + } + }) + }, [maxSteps]) const doPrevStep = useCallback(() => { - let newStep = currentStep - if (newStep <= 1) { - newStep = 1 - } else { - newStep = newStep - 1 - } - - setCurrentStep(newStep) - }, [currentStep]) + setCurrentStep((currentStep) => { + if (currentStep <= 1) { + return 1 + } else { + return currentStep - 1 + } + }) + }, []) const doSave = useCallback( - (e) => { + (e: FormEvent) => { e.preventDefault() - let saveConfig = {} + if (!oldConfig || !newConfig) return - for (const id in oldConfig) { + let saveConfig: Partial = {} + + for (const id0 in oldConfig) { + const id = id0 as keyof UserConfigModel if (oldConfig[id] !== newConfig[id]) { - saveConfig[id] = newConfig[id] + saveConfig[id] = newConfig[id] as any } } @@ -89,11 +98,14 @@ export const WizardModal = forwardRef(function WizardModal(_props, ref) { [socket, newConfig, oldConfig, doNextStep] ) - const setValue = (key, value) => { - setNewConfig((oldState) => ({ - ...oldState, - [key]: value, - })) + const setValue = (key: keyof UserConfigModel, value: any) => { + setNewConfig( + (oldState) => + oldState && { + ...oldState, + [key]: value, + } + ) } useImperativeHandle( @@ -146,12 +158,20 @@ export const WizardModal = forwardRef(function WizardModal(_props, ref) { {error ? {error} : ''} - {currentStep === 1 && !error ? : ''} - {currentStep === 2 && !error ? : ''} - {currentStep === 3 && !error ? : ''} - {currentStep === 4 && !error ? : ''} - {currentStep === 5 && !error ? : ''} - {currentStep === 6 && !error ? : ''} + {currentStep === 1 && newConfig && !error ? : ''} + {currentStep === 2 && newConfig && !error ? : ''} + {currentStep === 3 && newConfig && !error ? : ''} + {currentStep === 4 && newConfig && !error ? : ''} + {currentStep === 5 && newConfig && oldConfig && !error ? ( + + ) : ( + '' + )} + {currentStep === 6 && newConfig && startConfig && !error ? ( + + ) : ( + '' + )} {currentStep <= applyStep && ( diff --git a/webui/src/index.jsx b/webui/src/index.tsx similarity index 99% rename from webui/src/index.jsx rename to webui/src/index.tsx index 9a9cab654b..d34d1bca30 100644 --- a/webui/src/index.jsx +++ b/webui/src/index.tsx @@ -46,7 +46,7 @@ import { ConnectionDebug } from './ConnectionDebug' // }, // }) -const socket = new io() +const socket = io() if (window.location.hash && window.location.hash.includes('debug_socket')) { socket.onAny(function (name, ...data) { console.log('received event', name, data) diff --git a/webui/src/scss/_common.scss b/webui/src/scss/_common.scss index aab0651486..38f5588a67 100644 --- a/webui/src/scss/_common.scss +++ b/webui/src/scss/_common.scss @@ -152,6 +152,10 @@ code { display: none; } +.displayNone { + display: none; +} + .noBorder td { border: none; } diff --git a/webui/src/util.jsx b/webui/src/util.tsx similarity index 52% rename from webui/src/util.jsx rename to webui/src/util.tsx index 6449e40c0c..60bb15b660 100644 --- a/webui/src/util.jsx +++ b/webui/src/util.tsx @@ -1,34 +1,70 @@ -import React, { useEffect, useState } from 'react' +import React, { FormEvent, useEffect, useState } from 'react' import pTimeout from 'p-timeout' import { CAlert, CButton, CCol } from '@coreui/react' import { ErrorBoundary } from 'react-error-boundary' -import { PRIMARY_COLOR } from './Constants' +import { PRIMARY_COLOR } from './Constants.js' import { BarLoader } from 'react-spinners' -import { applyPatch } from 'fast-json-patch' +import { Operation as JsonPatchOperation, applyPatch } from 'fast-json-patch' import { cloneDeep } from 'lodash-es' import { useEventListener } from 'usehooks-ts' +import type { LoaderHeightWidthProps } from 'react-spinners/helpers/props.js' +import { Socket } from 'socket.io-client' +import type { AllVariableDefinitions } from '@companion/shared/Model/Variables.js' +import type { NotificationsManagerRef } from './Components/Notifications.js' +import type { + ClientConnectionConfig, + ClientEventDefinition, + ModuleDisplayInfo, +} from '@companion/shared/Model/Common.js' +import type { ClientTriggerData } from '@companion/shared/Model/TriggerModel.js' +import type { InternalFeedbackDefinition, ClientActionDefinition } from '@companion/shared/Model/Options.js' +import type { UserConfigModel } from '@companion/shared/Model/UserConfigModel.js' +import type { ClientDevicesListItem } from '@companion/shared/Model/Surfaces.js' +import type { PageModel } from '@companion/shared/Model/PageModel.js' +import type { CustomVariablesModel } from '@companion/shared/Model/CustomVariableModel.js' -export const SocketContext = React.createContext(null) -export const EventDefinitionsContext = React.createContext(null) -export const NotifierContext = React.createContext(null) -export const ModulesContext = React.createContext(null) -export const ActionsContext = React.createContext(null) -export const FeedbacksContext = React.createContext(null) -export const ConnectionsContext = React.createContext(null) -export const VariableDefinitionsContext = React.createContext(null) -export const CustomVariableDefinitionsContext = React.createContext(null) -export const UserConfigContext = React.createContext(null) -export const SurfacesContext = React.createContext(null) -export const PagesContext = React.createContext(null) -export const TriggersContext = React.createContext(null) -export const RecentActionsContext = React.createContext(null) -export const RecentFeedbacksContext = React.createContext(null) +export const SocketContext = React.createContext(null as any) // TODO - fix this +export const EventDefinitionsContext = React.createContext>({}) +export const NotifierContext = React.createContext>({ current: null }) // TODO - this is not good +/*({ + show: () => { + throw new Error('Not inside of context!') + }, +})*/ +export const ModulesContext = React.createContext>({}) +export const ActionsContext = React.createContext< + Record | undefined> +>({}) +export const FeedbacksContext = React.createContext< + Record | undefined> +>({}) +export const ConnectionsContext = React.createContext>({}) +export const VariableDefinitionsContext = React.createContext({}) +export const CustomVariableDefinitionsContext = React.createContext({}) +export const UserConfigContext = React.createContext(null) +export const SurfacesContext = React.createContext>({}) +export const PagesContext = React.createContext>({}) +export const TriggersContext = React.createContext>({}) +export const RecentActionsContext = React.createContext<{ + recentActions: string[] + trackRecentAction: (actionType: string) => void +} | null>(null) +export const RecentFeedbacksContext = React.createContext<{ + recentFeedbacks: string[] + trackRecentFeedback: (feedbackType: string) => void +} | null>(null) -export function socketEmitPromise(socket, name, args, timeout, timeoutMessage) { +export function socketEmitPromise( + socket: Socket, + name: string, + args: any[], + timeout?: number, + timeoutMessage?: string +): Promise { const p = new Promise((resolve, reject) => { console.log('send', name, ...args) - socket.emit(name, ...args, (err, res) => { + socket.emit(name, ...args, (err: Error, res: any) => { if (err) reject(err) else resolve(res) }) @@ -50,6 +86,7 @@ const freezePrototypes = () => { Object.freeze(console) Object.freeze(Array.prototype) Object.freeze(Function.prototype) + // @ts-ignore Object.freeze(Math.prototype) Object.freeze(Number.prototype) Object.freeze(Object.prototype) @@ -58,18 +95,23 @@ const freezePrototypes = () => { Object.freeze(Symbol.prototype) // prevent constructors of async/generator functions to bypass sandbox + // @ts-ignore Object.freeze(async function () {}.__proto__) + // @ts-ignore Object.freeze(async function* () {}.__proto__) + // @ts-ignore Object.freeze(function* () {}.__proto__) + // @ts-ignore Object.freeze(function* () {}.__proto__.prototype) + // @ts-ignore Object.freeze(async function* () {}.__proto__.prototype) } -export function sandbox(serializedFn) { +export function sandbox(serializedFn: string): (...args: any[]) => any { // proxy handler const proxyHandler = { has: () => true, - get: (obj, prop) => Reflect.get(obj, prop), + get: (obj: any, prop: any) => Reflect.get(obj, prop), } // global objects that will be allowed within the sandbox @@ -118,7 +160,11 @@ export function sandbox(serializedFn) { } } -function ErrorFallback({ error, resetErrorBoundary }) { +interface ErrorFallbackProps { + error: Error | undefined + resetErrorBoundary: () => void +} +function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) { return (

    Something went wrong:

    @@ -130,11 +176,12 @@ function ErrorFallback({ error, resetErrorBoundary }) { ) } -export function MyErrorBoundary({ children }) { +export function MyErrorBoundary({ children }: React.PropsWithChildren<{}>) { return {children} } -export function KeyReceiver({ children, ...props }) { +type KeyReceiverProps = React.PropsWithChildren> +export function KeyReceiver({ children, ...props }: KeyReceiverProps) { return (
    {children} @@ -143,9 +190,10 @@ export function KeyReceiver({ children, ...props }) { } // eslint-disable-next-line react-hooks/exhaustive-deps -export const useMountEffect = (fun) => useEffect(fun, []) +export const useMountEffect = (fun: React.EffectCallback) => useEffect(fun, []) -export function LoadingBar(props) { +type LoadingBarProps = LoaderHeightWidthProps +export function LoadingBar(props: LoadingBarProps) { return ( void + autoRetryAfter?: number | null +} +export function LoadingRetryOrError({ error, dataReady, doRetry, autoRetryAfter = null }: LoadingRetryOrErrorProps) { const [countdown, setCountdown] = useState(autoRetryAfter) useEffect(() => { if (!dataReady && autoRetryAfter) { const interval = setInterval(() => { setCountdown((c) => { - if (c <= 0) { + if (!c || c <= 0) { return autoRetryAfter - 1 } else { return c - 1 @@ -175,11 +229,12 @@ export function LoadingRetryOrError({ error, dataReady, doRetry, autoRetryAfter return () => clearInterval(interval) } else { setCountdown(null) + return } }, [dataReady, autoRetryAfter]) useEffect(() => { - if (countdown === 0) { + if (countdown === 0 && doRetry) { doRetry() } }, [countdown, doRetry]) @@ -207,9 +262,15 @@ export function LoadingRetryOrError({ error, dataReady, doRetry, autoRetryAfter ) } -export function applyPatchOrReplaceSubObject(oldDefinitions, key, patch, defVal = {}) { +export function applyPatchOrReplaceSubObject( + oldDefinitions: Record, + key: string, + patch: JsonPatchOperation[], + defVal: T | null +) { if (oldDefinitions) { const oldEntry = oldDefinitions[key] ?? defVal + if (!oldEntry) return oldDefinitions const newDefinitions = { ...oldDefinitions } if (!patch) { @@ -227,7 +288,7 @@ export function applyPatchOrReplaceSubObject(oldDefinitions, key, patch, defVal return oldDefinitions } } -export function applyPatchOrReplaceObject(oldObj, patch) { +export function applyPatchOrReplaceObject(oldObj: T, patch: JsonPatchOperation[] | T): T { const oldEntry = oldObj ?? {} if (Array.isArray(patch)) { @@ -242,13 +303,17 @@ export function applyPatchOrReplaceObject(oldObj, patch) { /** * Slight modification of useClickoutside from usehooks-ts, which expects an array of refs to check */ -export function useOnClickOutsideExt(refs, handler, mouseEvent = 'mousedown') { +export function useOnClickOutsideExt( + refs: React.RefObject[], + handler: (e: MouseEvent) => void, + mouseEvent: 'mousedown' | 'mouseup' = 'mousedown' +) { useEventListener(mouseEvent, (event) => { for (const ref of refs) { const el = ref?.current // Do nothing if clicking ref's element or descendent elements - if (!el || el.contains(event.target)) { + if (!el || el.contains(event.target as any)) { return } } @@ -257,6 +322,6 @@ export function useOnClickOutsideExt(refs, handler, mouseEvent = 'mousedown') { }) } -export const PreventDefaultHandler = (e) => { +export const PreventDefaultHandler = (e: FormEvent): void => { e.preventDefault() } diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 0000000000..026141f336 --- /dev/null +++ b/webui/tsconfig.json @@ -0,0 +1,38 @@ +{ + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx", "../lib/Shared/*.js"], + "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "paths": { + "*": ["./node_modules/*"] + }, + "module": "ESNext", + "moduleResolution": "node", + "jsx": "react", + "allowJs": true, + // "checkJs": true, + "strictPropertyInitialization": false, + + "target": "ESNext", + "noImplicitAny": true, + "sourceMap": true, + "declaration": false, + "importHelpers": false, + "listFiles": false, + "traceResolution": false, + "pretty": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["node", "react"], + "strict": true, + "alwaysStrict": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + } +} diff --git a/webui/yarn.lock b/webui/yarn.lock index c1e2bb1940..67ad203155 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -669,6 +669,20 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d" integrity sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g== +"@types/react-copy-to-clipboard@^5.0.7": + version "5.0.7" + resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.7.tgz#0cb724d4228f1c2f8f5675671b3971c8801d5f45" + integrity sha512-Gft19D+as4M+9Whq1oglhmK49vqPhcLzk8WfvfLvaYMIPYanyfLy0+CwFucMJfdKoSFyySPmkkWn8/E6voQXjQ== + dependencies: + "@types/react" "*" + +"@types/react-dom@^18.2.15": + version "18.2.15" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.15.tgz#921af67f9ee023ac37ea84b1bc0cc40b898ea522" + integrity sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.0": version "4.4.8" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.8.tgz#46f87d80512959cac793ecc610a93d80ef241ccf" @@ -676,15 +690,29 @@ dependencies: "@types/react" "*" -"@types/react@*": - version "18.2.36" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.36.tgz#bc68ffb4408e5d0c419b0760b2eaeec70aeeedb3" - integrity sha512-o9XFsHYLLZ4+sb9CWUYwHqFVoG61SesydF353vFMMsQziiyRu8np4n2OYMUSDZ8XuImxDr9c5tR7gidlH29Vnw== +"@types/react-window@^1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" + integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^17": + version "17.0.70" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.70.tgz#35301a9cb94ba1a65dc306b7ce169a2c4fda1986" + integrity sha512-yqYMK49/cnqw+T8R9/C+RNjRddYmPDGI5lKHi3bOYceQCBAh8X2ngSbZP0gnVeyvHr0T7wEgIIGKT1usNol08w== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" csstype "^3.0.2" +"@types/sanitize-html@^2.9.4": + version "2.9.4" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.9.4.tgz#bfc2df463ec35904fecc57b29ba080e53732a140" + integrity sha512-Ym4hjmAFxF/eux7nW2yDPAj2o9RYh0vP/9V5ECoHtgJ/O9nPGslUd20CMn6WatRMlFVfjMTg3lMcWq8YyO6QnA== + dependencies: + htmlparser2 "^8.0.0" + "@types/scheduler@*": version "0.16.5" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.5.tgz#4751153abbf8d6199babb345a52e1eb4167d64af" @@ -2518,6 +2546,11 @@ tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +typescript@~5.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== + unified@^10.0.0: version "10.1.2" resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"