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/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 diff --git a/bundled-modules b/bundled-modules index 5d63be3915..c934bf828e 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 5d63be3915c9ba860674f33f63a4a8c8bc8fb29f +Subproject commit c934bf828ea87c4620b44b750af17f026e41f078 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 f0bcba60f5..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/ ` + +- `/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/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/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/Cloud/Controller.js b/lib/Cloud/Controller.js index f9619b60e6..114c11ff21 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' || !control.supportsStyle) 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 @@ -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/ActionRecorder.js b/lib/Controls/ActionRecorder.js index fde4b856b1..ea559736e7 100644 --- a/lib/Controls/ActionRecorder.js +++ b/lib/Controls/ActionRecorder.js @@ -15,31 +15,9 @@ function SessionRoom(id) { } /** - * @typedef {{ - * id: string - * instanceIds: string[] - * isRunning: boolean - * actionDelay: number - * actions: RecordActionTmp[] - * }} RecordSessionInfo - */ - -/** - * @typedef {{ - * instanceIds: 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 */ /** @@ -76,12 +54,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 @@ -115,7 +93,7 @@ export default class ActionRecorder extends EventEmitter { // create the 'default' session this.#currentSession = { id: nanoid(), - instanceIds: [], + connectionIds: [], isRunning: false, actionDelay: 0, actions: [], @@ -190,12 +168,12 @@ export default class ActionRecorder extends EventEmitter { } ) client.onPromise( - 'action-recorder:session:set-instances', - (/** @type {string} */ sessionId, /** @type {string[]} */ instanceIds) => { + '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(instanceIds) + this.setSelectedConnectionIds(connectionIds) return true } @@ -348,7 +326,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 +346,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 +357,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]) @@ -406,17 +384,17 @@ 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.instanceIds.filter((id) => id !== instanceId) + // Remove the connection which has stopped + const newIds = this.#currentSession.connectionIds.filter((id) => id !== connectionId) - if (newIds.length !== this.#currentSession.instanceIds.length) { + if (newIds.length !== this.#currentSession.connectionIds.length) { this.commitChanges([this.#currentSession.id]) } } @@ -424,24 +402,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.instanceIds.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,62 +488,62 @@ export default class ActionRecorder extends EventEmitter { } /** - * Set the current instances being recorded from - * @param {Array} instanceIds0 + * Set the current connections being recorded from + * @param {Array} connectionIds0 */ - setSelectedInstanceIds(instanceIds0) { - if (!Array.isArray(instanceIds0)) 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 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]) } /** - * 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.instanceIds) { - targetRecordingInstanceIds.add(id) + for (const id of this.#currentSession.connectionIds) { + 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/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 c872165d21..b610698495 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 @@ -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!') } @@ -149,6 +149,15 @@ export default class ControlBase extends CoreBase { return null } + /** + * Get the complete style object of a button + * @returns {import('../Shared/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() @@ -230,23 +239,23 @@ 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 } /** * 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..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 */ @@ -86,11 +86,11 @@ export default class ButtonControlBase extends ControlBase { * @type {'good' | 'warning' | 'error'} * @access protected */ - bank_status = 'good' + button_status = 'good' /** * The config of this button - * @type {import('../../../Data/Model/ButtonModel.js').ButtonOptionsBase} + * @type {import('../../../Shared/Model/ButtonModel.js').ButtonOptionsBase} */ options @@ -139,19 +139,19 @@ 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 - const instance_ids = new Set() + // 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)) { if (!actions) continue for (const action of actions) { - instance_ids.add(action.instance) + connectionIds.add(action.instance) } } } @@ -159,11 +159,11 @@ 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) - if (instance_status) { + for (const connectionId of connectionIds) { + 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 { @@ -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 } @@ -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() { @@ -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 @@ -366,20 +366,20 @@ 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!') } /** - * 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) { @@ -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, @@ -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.updateButtonState(location, this.pushed, surfaceId) } this.triggerRedraw() @@ -484,17 +484,17 @@ 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 + * 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 */ - 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/Button/Normal.js b/lib/Controls/ControlTypes/Button/Normal.js index 5dad0e3619..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 */ @@ -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 */ @@ -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) { @@ -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) { @@ -544,11 +544,12 @@ 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() { const style = super.getDrawStyle() + if (!style) return style if (GetStepIds(this.steps).length > 1) { style.step_cycle = this.getActiveStepIndex() + 1 @@ -558,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: [], @@ -588,11 +589,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 +602,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, @@ -656,41 +657,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 +736,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 +769,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 +786,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, }) } } @@ -945,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] = { @@ -958,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 8953f74809..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() { @@ -121,12 +121,12 @@ 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 + * 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 */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } @@ -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) } } @@ -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 4cd51a43eb..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() { @@ -122,12 +122,12 @@ 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 + * 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 */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } @@ -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) } } @@ -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 3a1dc3a3ee..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() { @@ -121,12 +121,12 @@ 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 + * 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 */ - collectReferencedInstances(_foundInstanceIds, _foundInstanceLabels) { + collectReferencedConnections(_foundConnectionIds, _foundConnectionLabels) { // Nothing being referenced } @@ -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) } } @@ -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/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..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) { @@ -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) } /** @@ -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, }) } } @@ -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) } @@ -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, @@ -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) @@ -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, @@ -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) @@ -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 d145cfcf76..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 */ /** @@ -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( @@ -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) } } } @@ -188,7 +188,7 @@ class ControlsController extends CoreBase { } if (type) { - this.createBankControl(location, type) + this.createButtonControl(location, type) } } ) @@ -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) @@ -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 ) @@ -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) } ) @@ -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 { @@ -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)) { @@ -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) } } } @@ -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 @@ -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) @@ -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 */ @@ -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) } } } @@ -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 } @@ -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) @@ -1485,18 +1485,18 @@ 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()) - 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) @@ -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..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 @@ -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) } @@ -422,12 +425,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 +439,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 +505,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 +520,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 6cb543869f..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) @@ -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 @@ -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] @@ -486,7 +486,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 } @@ -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 { @@ -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: [], @@ -676,21 +676,21 @@ 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] 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 } @@ -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 6dbde2884f..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 */ /** @@ -119,16 +119,7 @@ export class ControlWithStyle extends ControlBase { * @type {'good' | 'warning' | 'error'} * @access protected */ - 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') - } + button_status = 'good' /** * Update the style fields of this control @@ -180,33 +171,23 @@ 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 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') } } @@ -290,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 @@ -377,22 +358,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') } @@ -572,10 +553,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 +636,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/Data/ImportExport.js b/lib/Data/ImportExport.js index 49608fee92..f3c4f81841 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 @@ -62,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} @@ -98,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, @@ -150,29 +151,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} + * @returns {import('../Shared/Model/ExportModel.js').ExportInstancesv4} */ const generate_export_for_referenced_instances = ( - referencedInstanceIds, - referencedInstanceLabels, + referencedConnectionIds, + referencedConnectionLabels, minimalExport = false ) => { - /** @type {import('./Model/ExportModel.js').ExportInstancesv4} */ + /** @type {import('../Shared/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) || {} } } @@ -182,23 +183,26 @@ 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 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,18 +246,18 @@ 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 - /** @type {import('./Model/ExportModel.js').ExportPageModelv4} */ + /** @type {import('../Shared/Model/ExportModel.js').ExportPageModelv4} */ const exp = { version: FILE_VERSION, type: 'page', @@ -269,13 +273,13 @@ class DataImportExport extends CoreBase { }) /** - * @param {Readonly} pageInfo - * @param {Set} referencedInstanceIds - * @param {Set} referencedInstanceLabels - * @returns {import('./Model/ExportModel.js').ExportPageContentv4} + * @param {Readonly} pageInfo + * @param {Set} referencedConnectionIds + * @param {Set} referencedConnectionLabels + * @returns {import('../Shared/Model/ExportModel.js').ExportPageContentv4} */ - const generatePageExportInfo = (pageInfo, referencedInstanceIds, referencedInstanceLabels) => { - /** @type {import('./Model/ExportModel.js').ExportPageContentv4} */ + const generatePageExportInfo = (pageInfo, referencedConnectionIds, referencedConnectionLabels) => { + /** @type {import('../Shared/Model/ExportModel.js').ExportPageContentv4} */ const pageExport = { name: pageInfo.name, controls: {}, @@ -289,7 +293,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) } } } @@ -304,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', @@ -312,8 +316,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,14 +326,14 @@ class DataImportExport extends CoreBase { for (const [pageNumber, rawPageInfo] of Object.entries(pageInfos)) { exp.pages[Number(pageNumber)] = generatePageExportInfo( rawPageInfo, - referencedInstanceIds, - referencedInstanceLabels + referencedConnectionIds, + referencedConnectionLabels ) } } if (!config || !isFalsey(config.triggers)) { - /** @type {Record} */ + /** @type {Record} */ const triggersExport = {} for (const control of rawControls.values()) { if (control.type === 'trigger') { @@ -337,7 +341,7 @@ class DataImportExport extends CoreBase { if (parsedId?.type === 'trigger') { triggersExport[parsedId.trigger] = control.toJSON(false) - control.collectReferencedInstances(referencedInstanceIds, referencedInstanceLabels) + control.collectReferencedConnections(referencedConnectionIds, referencedConnectionLabels) } } } @@ -351,11 +355,16 @@ 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)) { exp.surfaces = this.surfaces.exportAll(false) + exp.surfaceGroups = this.surfaces.exportAllGroups(false) } return exp @@ -500,8 +509,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, @@ -510,7 +518,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) } } } @@ -583,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, @@ -615,11 +623,12 @@ class DataImportExport extends CoreBase { clientObject.instances[instanceId] = { instance_type: this.instance.modules.verifyInstanceTypeIsCurrent(instance.instance_type), label: instance.label, + sortOrder: instance.sortOrder, } } /** - * @param {import('./Model/ExportModel.js').ExportPageContentv4} pageInfo + * @param {import('../Shared/Model/ExportModel.js').ExportPageContentv4} pageInfo * @returns {ClientPageInfo} */ function simplifyPageForClient(pageInfo) { @@ -702,7 +711,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, @@ -722,11 +731,9 @@ 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( + this.controls.createButtonControl( { pageNumber, ...location, @@ -786,9 +793,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) { @@ -808,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} @@ -983,7 +988,7 @@ class DataImportExport extends CoreBase { if (!skipNavButtons) { for (const { type, location } of default_nav_buttons_definitions) { - this.controls.createBankControl( + this.controls.createButtonControl( { pageNumber, ...location, @@ -1026,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} */ @@ -1035,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] @@ -1083,27 +1092,27 @@ 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 /** @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 } } - /** @type {import('./Model/TriggerModel.js').TriggerModel} */ + /** @type {import('../Shared/Model/TriggerModel.js').TriggerModel} */ const result = { type: 'trigger', options: cloneDeep(control.options), @@ -1113,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] @@ -1128,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] @@ -1151,8 +1160,8 @@ class DataImportExport extends CoreBase { this.fixupControlReferences( { - instanceLabels: instanceLabelRemap, - instanceIds: instanceIdRemap, + connectionLabels: connectionLabelRemap, + connectionIds: connectionIdRemap, }, undefined, allActions, @@ -1165,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 @@ -1179,19 +1188,19 @@ 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 } } - /** @type {import('./Model/ButtonModel.js').NormalButtonModel} */ + /** @type {import('../Shared/Model/ButtonModel.js').NormalButtonModel} */ const result = { type: 'button', options: cloneDeep(control.options), @@ -1201,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] @@ -1216,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, @@ -1228,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] @@ -1248,8 +1257,8 @@ class DataImportExport extends CoreBase { this.fixupControlReferences( { - instanceLabels: instanceLabelRemap, - instanceIds: instanceIdRemap, + connectionLabels: connectionLabelRemap, + connectionIds: connectionIdRemap, }, result.style, allActions, @@ -1264,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 @@ -1304,15 +1313,15 @@ 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 */ 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) @@ -1330,55 +1339,20 @@ export default DataImportExport /** * @typedef {Record} InstanceRemappings * @typedef {Record} InstanceAppliedRemappings - * + * * @typedef {{ - * instanceLabels?: Record - * instanceIds?: Record + * 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/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/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 850d12ca62..d3407e05df 100644 --- a/lib/Data/UserConfig.js +++ b/lib/Data/UserConfig.js @@ -25,8 +25,8 @@ import CoreBase from '../Core/Base.js' */ class DataUserConfig extends CoreBase { /** - * The defaults for the bank fields - * @type {import('./Model/UserConfigModel.js').UserConfigModel} + * The defaults for the user config fields + * @type {import('../Shared/Model/UserConfigModel.js').UserConfigModel} * @access public * @static */ @@ -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,10 +163,10 @@ 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} */ + /** @type {Partial} */ const legacy_config = { tcp_enabled: true, tcp_listen_port: 51234, @@ -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/Graphics/Controller.js b/lib/Graphics/Controller.js index bd91462cf6..0de8c5ffb0 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) { @@ -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, width, height, dataUrl, draw_style } = await this.#pool.exec('drawButtonImage', [ this.#drawOptions, buttonStyle, location, pagename, ]) - render = GraphicsRenderer.wrapDrawBankImage(buffer, draw_style, buttonStyle) + render = GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, buttonStyle) } } else { render = GraphicsRenderer.drawBlank(this.#drawOptions, 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 @@ -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 } @@ -220,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, @@ -229,7 +227,7 @@ class GraphicsController extends CoreBase { imageBuffers: [], pushed: false, cloud: false, - bank_status: undefined, + button_status: undefined, step_cycle: undefined, action_running: false, @@ -240,8 +238,11 @@ 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.wrapDrawBankImage(buffer, draw_style, drawStyle) + const { buffer, width, height, dataUrl, draw_style } = await this.#pool.exec('drawButtonImage', [ + this.#drawOptions, + drawStyle, + ]) + return GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, draw_style, drawStyle) } /** @@ -260,7 +261,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/Image.js b/lib/Graphics/Image.js index 77c8d2bf4b..05fd9bcd71 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 /** @@ -837,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 @@ -845,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 } @@ -989,6 +982,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..d8ea55e457 100644 --- a/lib/Graphics/ImageResult.js +++ b/lib/Graphics/ImageResult.js @@ -1,14 +1,14 @@ /** - * @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 { /** * Image data-url for socket.io clients - * @type {string | null} + * @type {string} * @access private */ - #dataUrl = null + #dataUrl /** * Image pixel buffer @@ -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,61 +44,27 @@ export class ImageResult { /** * @param {Buffer} buffer + * @param {number} width + * @param {number} height + * @param {string} dataUrl * @param {ImageResultStyle | undefined} style */ - constructor(buffer, style) { + constructor(buffer, width, height, dataUrl, style) { this.buffer = buffer + this.bufferWidth = width + this.bufferHeight = height + 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/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..169544edb8 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 @@ -62,56 +62,64 @@ 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.realwidth, img.realheight, img.toDataURLSync(), undefined) } /** - * 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('../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} 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, width, height, dataUrl, draw_style } = await GraphicsRenderer.drawButtonImageUnwrapped( + options, + drawStyle, + location, + pagename + ) + + return GraphicsRenderer.wrapDrawButtonImage(buffer, width, height, dataUrl, 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 {number} width + * @param {number} height + * @param {string} dataUrl + * @param {import('../Shared/Model/StyleModel.js').DrawStyleModel['style'] | undefined} draw_style + * @param {import('../Shared/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, width, height, 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, width, height, dataUrl, 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('../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, 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 drawBankImageUnwrapped(options, bankStyle, location, pagename) { - // console.log('starting drawBankImage '+ performance.now()) - // console.time('drawBankImage') + 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 - if (bankStyle.style == 'pageup') { + if (drawStyle.style == 'pageup') { draw_style = 'pageup' img.fillColor(colorDarkGrey) @@ -131,7 +139,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 +159,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,15 +174,18 @@ 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') + // console.timeEnd('drawButtonImage') return { buffer: img.buffer(), + width: img.realwidth, + height: img.realheight, + dataUrl: await img.toDataURL(), draw_style, } } @@ -183,35 +194,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('../Shared/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 +234,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 +259,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('../Shared/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 +304,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 +323,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 +350,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], @@ -365,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(), undefined) + return new ImageResult(img.buffer(), img.realwidth, img.realheight, img.toDataURLSync(), undefined) } /** @@ -381,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(), undefined) + return new ImageResult(img.buffer(), img.realwidth, img.realheight, img.toDataURLSync(), undefined) } } diff --git a/lib/Graphics/Thread.js b/lib/Graphics/Thread.js index f174a4def9..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.drawBankImageUnwrapped, + drawButtonImage: GraphicsRenderer.drawButtonImageUnwrapped, }) diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index 61f1dbdcad..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 {{ @@ -168,7 +162,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) } @@ -316,9 +310,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 +343,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) } /** @@ -415,7 +409,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) } } @@ -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] @@ -440,8 +434,6 @@ class Instance extends CoreBase { : { ...rawObj, } - // @ts-ignore types - delete obj.sortOrder return clone ? cloneDeep(obj) : obj } @@ -457,20 +449,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) + getConnectionStatus(connectionId) { + return this.status.getConnectionStatus(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 +514,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 +558,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 +575,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 +603,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/CustomVariable.js b/lib/Instance/CustomVariable.js index 1c956a6091..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) { @@ -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/Instance/Definitions.js b/lib/Instance/Definitions.js index 6661c22a8b..d7782ba1f8 100644 --- a/lib/Instance/Definitions.js +++ b/lib/Instance/Definitions.js @@ -103,12 +103,12 @@ 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', - 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, } @@ -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, @@ -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, @@ -229,35 +229,35 @@ 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] + forgetConnection(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) } } /** * 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,31 +265,31 @@ 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 } } /** - * 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 - /** @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, @@ -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 connection 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 /** @@ -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)) { @@ -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) } } } @@ -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/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/Modules.js b/lib/Instance/Modules.js index 19b70ed879..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 { @@ -249,7 +238,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..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 { @@ -59,7 +55,7 @@ export default class Status extends EventEmitter { * @access public */ clientConnect(client) { - client.onPromise('instance_status:get', () => { + client.onPromise('connections:get-statuses', () => { return this.#instanceStatuses }) } @@ -139,7 +135,7 @@ export default class Status extends EventEmitter { * @param {String} connectionId * @returns {StatusEntry} */ - getInstanceStatus(connectionId) { + getConnectionStatus(connectionId) { return this.#instanceStatuses[connectionId] } @@ -147,7 +143,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] @@ -166,7 +162,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..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 @@ -67,9 +66,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 +76,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 @@ -109,7 +108,7 @@ class InstanceVariable extends CoreBase { #variableValues = {} /** - * @type {Record | undefined>} + * @type {import('../Shared/Model/Variables.js').AllVariableDefinitions} */ #variableDefinitions = {} @@ -221,7 +220,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) { @@ -247,11 +246,11 @@ class InstanceVariable extends CoreBase { * @param {string} labelTo * @returns {void} */ - instanceLabelRename(labelFrom, labelTo) { + connectionLabelRename(labelFrom, labelTo) { 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' @@ -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 4cf1b2f23e..a144975c6b 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) { @@ -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} */ @@ -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}`) @@ -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, @@ -877,7 +879,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/ActionRecorder.js b/lib/Internal/ActionRecorder.js index 55d3b49a1c..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 */ @@ -192,7 +196,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) @@ -220,7 +224,7 @@ export default class ActionRecorder { break } - this.#actionRecorder.setSelectedInstanceIds(Array.from(result)) + this.#actionRecorder.setSelectedConnectionIds(Array.from(result)) } return true @@ -336,9 +340,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) } } @@ -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 f4446c4f3c..f2724849d9 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 @@ -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 8aef9dc86f..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,26 +66,28 @@ 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)', id: 'location_text', default: '$(this:page)/$(this:row)/$(this:column)', isVisible: (options) => options.location_target === 'text', 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)}`', id: 'location_expression', default: `concat($(this:page), '/', $(this:row), '/', $(this:column))`, isVisible: (options) => options.location_target === 'expression', useVariables: true, // @ts-ignore useInternalLocationVariables: true, - }, + }), ] /** @type {import('./Types.js').InternalActionInputField[]} */ @@ -96,7 +98,7 @@ const CHOICES_STEP_WITH_VARIABLES = [ id: 'step_from_expression', default: false, }, - { + serializeIsVisibleFnSingle({ type: 'number', label: 'Step', tooltip: 'Which Step?', @@ -105,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 = [ @@ -277,6 +279,7 @@ export default class Controls { return { button_pressrelease: { label: 'Button: Trigger press and release', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -290,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: [ { @@ -312,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: [ { @@ -345,6 +350,7 @@ export default class Controls { button_press: { label: 'Button: Trigger press', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -358,6 +364,7 @@ export default class Controls { }, button_release: { label: 'Button: Trigger release', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -385,6 +392,7 @@ export default class Controls { button_text: { label: 'Button: Set text', + description: undefined, showButtonPreview: true, options: [ { @@ -398,6 +406,7 @@ export default class Controls { }, textcolor: { label: 'Button: Set text color', + description: undefined, showButtonPreview: true, options: [ { @@ -411,6 +420,7 @@ export default class Controls { }, bgcolor: { label: 'Button: Set background color', + description: undefined, showButtonPreview: true, options: [ { @@ -425,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, @@ -438,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, { @@ -450,6 +462,7 @@ export default class Controls { }, panic_trigger: { label: 'Actions: Abort delayed actions on a trigger', + description: undefined, options: [ { type: 'internal:trigger', @@ -462,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: [ { @@ -505,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: [ { @@ -520,6 +537,7 @@ export default class Controls { }, bank_current_step_delta: { label: 'Button: Skip step', + description: undefined, showButtonPreview: true, options: [ ...CHOICES_DYNAMIC_LOCATION, @@ -578,7 +596,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), @@ -600,25 +618,33 @@ 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 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 } } @@ -729,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 @@ -826,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 */ @@ -837,8 +863,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 +875,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') { @@ -859,41 +885,41 @@ 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 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) @@ -970,8 +996,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 @@ -1016,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 192d55a2b9..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 */ @@ -290,8 +296,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') { @@ -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 2e7af9e895..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 */ @@ -253,7 +255,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 +295,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': @@ -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 682ce98831..e3a417e280 100644 --- a/lib/Internal/Surface.js +++ b/lib/Internal/Surface.js @@ -17,36 +17,59 @@ 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, -} +import { serializeIsVisibleFnSingle } from '../Resources/Util.js' /** @type {import('./Types.js').InternalActionInputField[]} */ -const CHOICES_CONTROLLER_WITH_VARIABLES = [ +const CHOICES_SURFACE_GROUP_WITH_VARIABLES = [ { type: 'checkbox', label: 'Use variables for surface', id: 'controller_from_variable', default: false, }, - { - ...CHOICES_CONTROLLER, + 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 / controller', + label: 'Surface / group', id: 'controller_variable', default: 'self', isVisible: (options) => !!options.controller_from_variable, useVariables: true, + }), +] + +/** @type {import('./Types.js').InternalActionInputField[]} */ +const CHOICES_SURFACE_ID_WITH_VARIABLES = [ + { + type: 'checkbox', + label: 'Use variables for surface', + id: 'controller_from_variable', + default: false, }, + serializeIsVisibleFnSingle({ + type: 'internal:surface_serial', + label: 'Surface / group', + id: 'controller', + default: 'self', + 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[]} */ @@ -57,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 { @@ -146,7 +169,7 @@ export default class Surface { theController = theController.trim() - if (info && theController === 'self') theController = info.deviceid + if (info && theController === 'self') theController = info.surfaceId return theController } @@ -195,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 @@ -215,9 +238,10 @@ export default class Surface { getActionDefinitions() { return { set_brightness: { - label: 'Surface: Set serialNumber to brightness', + label: 'Surface: Set to brightness', + description: undefined, options: [ - ...CHOICES_CONTROLLER_WITH_VARIABLES, + ...CHOICES_SURFACE_ID_WITH_VARIABLES, { type: 'number', @@ -233,11 +257,13 @@ 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', + 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', @@ -255,33 +281,40 @@ export default class Surface { inc_page: { label: 'Surface: Increment page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + description: undefined, + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, dec_page: { label: 'Surface: Decrement page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + description: undefined, + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_device: { label: 'Surface: Lockout specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + description: undefined, + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, unlockout_device: { label: 'Surface: Unlock specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + 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: [], }, } @@ -289,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 */ @@ -313,9 +346,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,16 +370,16 @@ 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) } } setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, true, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, true, true) }) } return true @@ -355,7 +388,7 @@ export default class Surface { if (!theController) return true setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, false, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, false, true) }) return true @@ -365,7 +398,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) } } @@ -467,7 +500,7 @@ export default class Surface { options: [ { type: 'internal:surface_serial', - label: 'Surface / controller', + label: 'Surface / group', id: 'controller', }, { @@ -502,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 1a2e239208..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,53 +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 - } - | { - 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/Registry.js b/lib/Registry.js index 9c70831e45..322401cc47 100644 --- a/lib/Registry.js +++ b/lib/Registry.js @@ -248,12 +248,12 @@ 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) - // 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/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 ef00c1526e..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 @@ -100,6 +101,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 */ @@ -370,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/Api.js b/lib/Service/Api.js deleted file mode 100644 index 67b2306219..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+) :deviceId', (match) => { - const page = parseInt(match.page) - const deviceId = match.deviceId - - this.surfaces.devicePageSet(deviceId, page) - - return `If ${deviceId} is connected` - }) - - this.#router.addPath('page-up :deviceId', (match) => { - const deviceId = match.deviceId - - this.surfaces.devicePageUp(deviceId) - - return `If ${deviceId} is connected` - }) - - this.#router.addPath('page-down :deviceId', (match) => { - const deviceId = match.deviceId - - this.surfaces.devicePageDown(deviceId) - - return `If ${deviceId} 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/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/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/ElgatoPlugin.js b/lib/Service/ElgatoPlugin.js index 3581ab7d6b..ff141066dd 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,54 @@ 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 {import('../Graphics/ImageResult.js').ImageResult} */ render + ) => { + const targetSize = 72 // Compatibility + try { + 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) + + 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) + } } /** diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index 2e848a5298..571f472412 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('../Shared/Model/UserConfigModel.js').UserConfigGridSize} 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,72 +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) - - /** @type {any} */ - let drawStyle = {} - if (control && control.supportsStyle) { - drawStyle = control.getDrawStyle() || {} - } + const controlId = this.page.getControlIdAtOldBankIndex(pageNumber, bank) + const control = controlId ? this.controls.getControl(controlId) : undefined + + /** @type {import('../Shared/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 @@ -181,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 ) } @@ -191,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('../Shared/Model/UserConfigModel.js').UserConfigGridSize} */ + 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('../Shared/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 @@ -233,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() + ), }), } @@ -240,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) { @@ -265,20 +408,84 @@ 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 LEGACY_NODE_TEXT: { + this.logger.silly(`Change button ${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 LEGACY_NODE_TEXT_COLOR: { + const color = parseHexColor(value + '') + this.logger.silly(`Change button ${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 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 NODE_TEXT: { + case LOCATION_NODE_TEXT: { this.logger.silly(`Change bank ${controlId} text to ${value}`) const control = this.controls.getControl(controlId) @@ -291,7 +498,7 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_TEXT_COLOR: { + case LOCATION_NODE_TEXT_COLOR: { const color = parseHexColor(value + '') this.logger.silly(`Change bank ${controlId} text color to ${value} (${color})`) @@ -305,9 +512,9 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_BG_COLOR: { + case LOCATION_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) { @@ -329,24 +536,26 @@ 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) { + updateButtonState(location, pushed, surfaceId) { if (!this.server) return - if (deviceid === 'emberplus') return + if (surfaceId === 'emberplus') return const bank = xyToOldBankIndex(location.column, location.row) 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) } /** @@ -358,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) ) } @@ -390,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/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)) 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/Shared/Import.js b/lib/Shared/Import.js new file mode 100644 index 0000000000..711bc91e38 --- /dev/null +++ b/lib/Shared/Import.js @@ -0,0 +1,21 @@ +/** + * @typedef {{label?: string; sortOrder?: number}} MinimalInstanceInfo + */ + +/** + * + * @param {[id: string, obj: MinimalInstanceInfo | undefined]} param0 + * @param {[id: string, obj: MinimalInstanceInfo | 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/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 90% rename from lib/Data/Model/ExportModel.ts rename to lib/Shared/Model/ExportModel.ts index 0227358e5e..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' @@ -14,6 +15,7 @@ export interface ExportFullv4 extends ExportBase<'full'> { custom_variables?: CustomVariablesModel instances?: ExportInstancesv4 surfaces?: unknown + surfaceGroups?: unknown } export interface ExportPageModelv4 extends ExportBase<'page'> { @@ -33,7 +35,7 @@ export interface ExportPageContentv4 { name: string controls: Record> - gridSize: ExportGridSize + gridSize: UserConfigGridSize } export type ExportControlv4 = Record // TODO @@ -49,18 +51,12 @@ export type ExportInstanceFullv4 = { lastUpgradeIndex: number instance_type: string enabled: boolean - sortOrder: number + sortOrder?: number } export type ExportInstanceMinimalv4 = { label: string instance_type: string lastUpgradeIndex: number -} - -export interface ExportGridSize { - minColumn: number - maxColumn: number - minRow: number - maxRow: number + sortOrder?: 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 94% rename from lib/Data/Model/StyleModel.ts rename to lib/Shared/Model/StyleModel.ts index c11f32d251..5d27a64c69 100644 --- a/lib/Data/Model/StyleModel.ts +++ b/lib/Shared/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/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 80% rename from lib/Data/Model/UserConfigModel.ts rename to lib/Shared/Model/UserConfigModel.ts index e19c0cde27..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 @@ -19,14 +17,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 @@ -57,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 67a4c51087..289b47c7f0 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 {Record | 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.deviceId, timeout)) { + for (const surfaceGroup of this.#surfaceGroups.values()) { + if (this.#isSurfaceGroupTimedOut(surfaceGroup.groupId, timeout)) { doLockout = true - this.#surfacesLastInteraction.delete(device.deviceId) + 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.deviceId, timeout)) { - this.#surfacesLastInteraction.delete(device.deviceId) - this.setDeviceLocked(device.deviceId, 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} deviceId + * @param {string} groupId * @param {number} timeout * @returns {boolean} */ - #isSurfaceTimedOut(deviceId, timeout) { + #isSurfaceGroupTimedOut(groupId, timeout) { if (!this.isPinLockEnabled()) return false - const lastInteraction = this.#surfacesLastInteraction.get(deviceId) || 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 deviceId = 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(deviceId, timeout) - } else { - isLocked = !this.#surfacesLastInteraction.has(deviceId) - } - } - 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(deviceId, Date.now()) + const groupId = handler.getGroupId() || handler.surfaceId + this.#surfacesLastInteraction.set(groupId, 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()) + 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(deviceId, Date.now()) - } + + // Update the group to have the new surface + this.#attachSurfaceToGroup(handler) } /** @@ -307,20 +307,20 @@ 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) - 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.deviceId == 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.deviceId == 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.deviceId == 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.deviceId == 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,106 +719,139 @@ 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.deviceId, 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() } 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 || {}) @@ -744,7 +964,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 || @@ -839,13 +1059,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) @@ -899,20 +1118,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} deviceId - * @param {*} config + * @param {Record} surfaceGroups + * @param {Record} surfaces * @returns {void} */ - importSurface(deviceId, config) { - const device = this.#getSurfaceHandlerForId(deviceId, true) - if (device) { - // Device is currently loaded - device.setPanelConfig(config) - } else { - // Device is not loaded - this.setDeviceConfig(deviceId, 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() @@ -925,17 +1177,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) } @@ -944,9 +1199,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 } @@ -962,9 +1218,9 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ getDeviceIdFromIndex(index) { - for (const dev of Object.values(this.getDevicesList().available)) { - if (dev.index === index) { - return dev.id + for (const group of this.getDevicesList()) { + if (group.index === index) { + return group.id } } return undefined @@ -972,63 +1228,75 @@ class SurfaceController extends CoreBase { /** * Perform page-up for a surface - * @param {string} deviceId - * @param {boolean} looseIdMatching + * @param {string} surfaceOrGroupId + * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageUp(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, 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} deviceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageDown(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, 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} deviceId + * @param {string} surfaceOrGroupId * @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) - 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} deviceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {number | undefined} */ - devicePageGet(deviceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(deviceId, 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') } } } @@ -1056,21 +1324,21 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!locked - for (const device of this.#surfaceHandlers.values()) { - this.#surfacesLastInteraction.set(device.deviceId, 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} deviceId + * @param {string} surfaceOrGroupId * @param {boolean} locked * @param {boolean} looseIdMatching * @returns {void} */ - setDeviceLocked(deviceId, locked, looseIdMatching = false) { + setSurfaceOrGroupLocked(surfaceOrGroupId, locked, looseIdMatching = false) { if (!this.isPinLockEnabled()) return if (this.userconfig.getKey('link_lockouts')) { @@ -1080,32 +1348,51 @@ 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(surfaceOrGroupId) } else { - this.#surfacesLastInteraction.set(deviceId, Date.now()) + this.#surfacesLastInteraction.set(surfaceOrGroupId, Date.now()) } - const device = this.#getSurfaceHandlerForId(deviceId, looseIdMatching) - if (device) { - device.setLocked(!!locked) + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.setLocked(!!locked) } } } /** * 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) } } + /** + * 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 @@ -1115,53 +1402,36 @@ 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.deviceId === 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 deviceId2 = `streamdeck:${surfaceId}` - device = instances.find((d) => d.deviceId === deviceId2) - if (device) return device + let surfaceId2 = `streamdeck:${surfaceId}` + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // it is unlikely, but it could be a loupedeck - deviceId2 = `loupedeck:${surfaceId}` - device = instances.find((d) => d.deviceId === deviceId2) - if (device) return device + surfaceId2 = `loupedeck:${surfaceId}` + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // or maybe a satellite? - deviceId2 = `satellite-${surfaceId}` - return instances.find((d) => d.deviceId === deviceId2) + surfaceId2 = `satellite-${surfaceId}` + return surfaces.find((d) => d.surfaceId === surfaceId2) } } 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 {{ - * available: Record - * offline: Record - * }} ClientDevicesList + * @typedef {import('../Shared/Model/Surfaces.js').ClientSurfaceItem} ClientSurfaceItem + * @typedef {import('../Shared/Model/Surfaces.js').ClientDevicesListItem} 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 218c259492..956d49f4de 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) { - super(registry, `device(${panel.info.deviceId})`, `Surface/Handler/${panel.info.deviceId}`) + 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,11 +218,10 @@ class SurfaceHandler extends CoreBase { this.#pincodeCodePosition = [3, 4] } - this.#currentPage = 1 // The current page of the device - // 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) @@ -227,23 +236,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 +275,27 @@ class SurfaceHandler extends CoreBase { this.panel.setConfig(config, true) } - this.surfaces.emit('surface_page', this.deviceId, 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, @@ -282,32 +303,12 @@ class SurfaceHandler extends CoreBase { } } - get deviceId() { + get surfaceId() { return this.panel.info.deviceId } - #deviceIncreasePage() { - this.#currentPage++ - if (this.#currentPage >= 100) { - this.#currentPage = 1 - } - if (this.#currentPage <= 0) { - this.#currentPage = 99 - } - - this.#storeNewDevicePage(this.#currentPage) - } - - #deviceDecreasePage() { - 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 +369,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 +380,9 @@ class SurfaceHandler extends CoreBase { if (this.#isSurfaceLocked != locked) { this.#isSurfaceLocked = !!locked - this.#drawPage() + if (!skipDraw) { + this.#drawPage() + } } } @@ -388,7 +392,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) { @@ -434,12 +438,20 @@ 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() { if (!this.panel) return + this.#surfaceConfig.gridSize = this.panel.gridSize + this.#saveConfig() + this.#drawPage() } @@ -452,7 +464,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') @@ -485,7 +498,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 { @@ -496,12 +509,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 +523,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 +537,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') @@ -547,12 +560,14 @@ 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 { // Ignore when locked out } + } catch (e) { + this.logger.error(`Click failed: ${e}`) } } @@ -608,49 +623,50 @@ class SurfaceHandler extends CoreBase { } } - doPageDown() { - if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#deviceIncreasePage() - } else { - this.#deviceDecreasePage() - } + /** + * 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.#deviceDecreasePage() - } else { - this.#deviceIncreasePage() - } + 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 +675,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,11 +720,10 @@ 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.deviceId, newpage) + this.surfaces.emit('surface_page', this.surfaceId, newpage) if (defer) { setImmediate(() => { @@ -742,13 +748,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/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/Surface/IP/ElgatoPlugin.js b/lib/Surface/IP/ElgatoPlugin.js index d54f292071..fa26190fa9 100644 --- a/lib/Surface/IP/ElgatoPlugin.js +++ b/lib/Surface/IP/ElgatoPlugin.js @@ -17,31 +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 { PNG } from 'pngjs' 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 @@ -64,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 @@ -74,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', @@ -160,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 - } - } - ) } /** @@ -213,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 @@ -221,30 +175,7 @@ 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'), - }) - } else { - this.write_queue.queue(key, render.buffer) - } + this.socket.fillImage(key, { keyIndex: key - 1 }, render) } } @@ -265,6 +196,8 @@ class SurfaceIPElgatoPlugin extends EventEmitter { */ setConfig(config, _force) { this._config = config + + this.socket.rotation = this._config.rotation } } 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 0d94704874..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) @@ -192,7 +191,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, @@ -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..7a527876e6 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) @@ -333,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 79823f09be..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}`, @@ -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) diff --git a/lib/UI/Express.js b/lib/UI/Express.js index ad8722a387..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,191 +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 { - const data = req.query.png64.replace(/^.*base64,/, '') - newFields.png64 = data - } - } - - 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/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/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/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/package.json b/package.json index 82fc265468..d0db3fe36c 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", @@ -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/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) + }) }) }) 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/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.scss b/webui/src/App.scss index 75c51c7d46..181e040cde 100644 --- a/webui/src/App.scss +++ b/webui/src/App.scss @@ -7,9 +7,10 @@ @import 'scss/layout'; @import 'scss/common'; @import 'scss/tablet'; +@import 'scss/emulator'; @import 'scss/action-recorder'; -@import 'scss/bank'; +@import 'scss/button-control'; @import 'scss/button-edit'; @import 'scss/button-grid'; @import 'scss/controls'; @@ -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/App.jsx b/webui/src/App.tsx similarity index 90% rename from webui/src/App.jsx rename to webui/src/App.tsx index 640f682c08..5786b9918d 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.tsx @@ -35,11 +35,11 @@ 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' -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 @@ -444,7 +470,7 @@ function AppContent({ buttonGridHotPress }) { - + diff --git a/webui/src/Buttons/ActionRecorder.jsx b/webui/src/Buttons/ActionRecorder.tsx similarity index 67% rename from webui/src/Buttons/ActionRecorder.jsx rename to webui/src/Buttons/ActionRecorder.tsx index 22eae19f9d..c2f3fd2862 100644 --- a/webui/src/Buttons/ActionRecorder.jsx +++ b/webui/src/Buttons/ActionRecorder.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useContext, useEffect, useState, useRef } from 'react' +import React, { useCallback, useContext, useEffect, useState, useRef, RefObject, ChangeEvent } from 'react' import { - InstancesContext, + ConnectionsContext, socketEmitPromise, SocketContext, LoadingRetryOrError, @@ -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,18 +241,22 @@ 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 bankClick = useCallback( + const buttonClick = useCallback( (location, pressed) => { if (pressed) setSelectedLocation(location) }, @@ -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 bank 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 bank 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]) @@ -397,11 +417,11 @@ 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 && + ) ) : ( - + There currently are no triggers or scheduled tasks. @@ -516,9 +547,16 @@ 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 instances = useContext(InstancesContext) + const connections = useContext(ConnectionsContext) const doClearActions = useCallback(() => { socketEmitPromise(socket, 'action-recorder:session:discard-actions', [sessionId]).catch((e) => { @@ -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, @@ -559,19 +597,19 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } doFinish() }, [changeRecording, doFinish]) - const changeInstanceIds = useCallback( - (ids) => { - socketEmitPromise(socket, 'action-recorder:session:set-instances', [sessionId, ids]).catch((e) => { + const changeConnectionIds = useCallback( + (ids: DropdownChoiceId[]) => { + 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,9 +619,7 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish } } return result - }, [instances]) - - if (!sessionInfo) return <> + }, [connections]) return ( <> @@ -592,11 +628,11 @@ function RecorderSessionHeading({ confirmRef, sessionId, sessionInfo, doFinish }
Connections - + value={sessionInfo.connectionIds} + setValue={changeConnectionIds} multiple={true} - choices={instancesWhichCanRecord} + choices={connectionsWhichCanRecord} />
@@ -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', @@ -99,11 +110,11 @@ export const ButtonGridActions = forwardRef(function ButtonGridActions( useImperativeHandle( ref, () => ({ - 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 +145,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/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 60% rename from webui/src/Buttons/ButtonGridPanel.jsx rename to webui/src/Buttons/ButtonGridPanel.tsx index bcd73f4bca..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,22 +53,22 @@ 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 bankClick = useCallback( + const buttonClick = useCallback( (location, isDown) => { - if (!actionsRef.current?.bankClick(location, isDown)) { + if (!actionsRef.current?.buttonClick(location, isDown)) { buttonGridClick(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,12 +196,12 @@ 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 5e009a4744..0000000000 --- a/webui/src/Buttons/ButtonInfiniteGrid.jsx +++ /dev/null @@ -1,253 +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, bankClick, 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: bankClick, - selected: - selectedButton?.pageNumber === pageNumber && - selectedButton?.column === column && - selectedButton?.row === row, - style: { - 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 - - 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_bank', [dropData.instanceId, 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, ...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 67% rename from webui/src/Buttons/EditButton.jsx rename to webui/src/Buttons/EditButton.tsx index 4f6ea0a709..4d4caa3ca6 100644 --- a/webui/src/Buttons/EditButton.jsx +++ b/webui/src/Buttons/EditButton.tsx @@ -39,10 +39,12 @@ import React, { useRef, 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, @@ -52,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' @@ -61,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 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()) @@ -94,27 +105,29 @@ 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) => { + 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 } }) } @@ -122,7 +135,7 @@ export function EditButton({ location, onKeyUp, contentHeight }) { 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) @@ -139,7 +152,7 @@ export function EditButton({ location, onKeyUp, contentHeight }) { }, [socket, controlId, reloadConfigToken]) const setButtonType = useCallback( - (newType) => { + (newType: string) => { let show_warning = false const currentType = configRef.current?.type @@ -161,7 +174,7 @@ export function EditButton({ location, onKeyUp, contentHeight }) { } 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', @@ -178,7 +191,7 @@ export function EditButton({ location, onKeyUp, contentHeight }) { 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', @@ -211,16 +224,16 @@ export function EditButton({ location, onKeyUp, contentHeight }) { ) }, [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 ( @@ -228,10 +241,11 @@ export function EditButton({ location, onKeyUp, contentHeight }) { {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 */} @@ -254,91 +268,100 @@ export function EditButton({ location, onKeyUp, contentHeight }) { setButtonType('pagedown')}>Page down - )} -   - -   - - - {' '} -   - {config?.options?.rotaryActions && ( - <> - - - -   - - - - - )} - - + + ))} +   - - -
- -
+ +   + + + {' '}
- {config && runtimeProps && ( +   + {config && 'options' in config && config?.options?.rotaryActions && ( - + + + +   + + + )} + {controlId && config && config.type === 'button' && ( + <> + + + +
+ +
+
+ {runtimeProps && ( + + + + )} + + )} )}
) +}) + +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 }) { +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() @@ -351,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 @@ -379,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]) @@ -396,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) }) @@ -406,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}`) @@ -418,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) }) @@ -427,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) }) @@ -435,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) }) @@ -448,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 (
@@ -459,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 @@ -514,6 +537,8 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc controlId={controlId} feedbacks={feedbacks} location={location} + booleanOnly={false} + addPlaceholder="+ Add feedback" /> )} @@ -550,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} > @@ -564,7 +589,7 @@ function TabsSection({ style, controlId, location, steps, runtimeProps, rotaryAc - {rotaryActions && ( + {rotaryActions && selectedStep2 && ( <> )} - - - + {selectedStep2 && ( + <> + + + - + + + )}

@@ -629,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(() => { @@ -662,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)) @@ -713,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) @@ -741,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.jsx deleted file mode 100644 index cea18f331e..0000000000 --- a/webui/src/Buttons/Presets.jsx +++ /dev/null @@ -1,252 +0,0 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { CAlert, CButton, CRow } from '@coreui/react' -import { - InstancesContext, - LoadingRetryOrError, - socketEmitPromise, - applyPatchOrReplaceSubObject, - SocketContext, - ModulesContext, -} from '../util' -import { useDrag } from 'react-dnd' -import { ButtonPreview, RedImage } from '../Components/ButtonPreview' -import { nanoid } from 'nanoid' - -export const InstancePresets = function InstancePresets({ resetToken }) { - const socket = useContext(SocketContext) - const modules = useContext(ModulesContext) - const instancesContext = useContext(InstancesContext) - - const [instanceAndCategory, setInstanceAndCategory] = useState([null, null]) - const [presetsMap, setPresetsMap] = useState(null) - const [presetsError, setPresetError] = useState(null) - const [reloadToken, setReloadToken] = useState(nanoid()) - - const doRetryPresetsLoad = useCallback(() => setReloadToken(nanoid()), []) - - // Reset selection on resetToken change - useEffect(() => { - setInstanceAndCategory([null, null]) - }, [resetToken]) - - useEffect(() => { - setPresetsMap(null) - setPresetError(null) - - socketEmitPromise(socket, 'presets:subscribe', []) - .then((data) => { - setPresetsMap(data) - }) - .catch((e) => { - console.error('Failed to load presets') - setPresetError('Failed to load presets') - }) - - const updatePresets = (id, patch) => { - setPresetsMap((oldPresets) => applyPatchOrReplaceSubObject(oldPresets, id, patch, [])) - } - - socket.on('presets:update', updatePresets) - - return () => { - socket.off('presets:update', updatePresets) - - socketEmitPromise(socket, 'presets:unsubscribe', []).catch((e) => { - console.error('Failed to unsubscribe to presets') - }) - } - }, [socket, reloadToken]) - - if (!presetsMap) { - // Show loading or an error - return ( - - - - ) - } - - if (instanceAndCategory[0]) { - const instance = instancesContext[instanceAndCategory[0]] - const module = instance ? modules[instance.instance_type] : undefined - - const presets = presetsMap[instanceAndCategory[0]] ?? [] - - if (instanceAndCategory[1]) { - return ( - - ) - } else { - return ( - - ) - } - } else { - return - } -} - -function PresetsInstanceList({ presets, setInstanceAndCategory }) { - const modules = useContext(ModulesContext) - const instancesContext = useContext(InstancesContext) - - const options = Object.entries(presets).map(([id, vals]) => { - if (!vals || Object.values(vals).length === 0) return '' - - const instance = instancesContext[id] - const module = instance ? modules[instance.instance_type] : undefined - - return ( -

- setInstanceAndCategory([id, null])} - > - {module?.name ?? '?'} ({instance?.label ?? id}) - -
- ) - }) - - return ( -
-
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. -

- {options.length === 0 ? ( - You have no connections that support presets at the moment. - ) : ( - options - )} -
- ) -} - -function PresetsCategoryList({ presets, instance, module, selectedInstanceId, setInstanceAndCategory }) { - const categories = new Set() - for (const preset of Object.values(presets)) { - categories.add(preset.category) - } - - const doBack = useCallback(() => setInstanceAndCategory([null, null]), [setInstanceAndCategory]) - - const buttons = Array.from(categories).map((category) => { - return ( - setInstanceAndCategory([selectedInstanceId, category])} - > - {category} - - ) - }) - - return ( -
-
- - Back - - {module?.name ?? '?'} ({instance?.label ?? selectedInstanceId}) -
- - {buttons.length === 0 ? ( - Connection has no presets. - ) : ( -
{buttons}
- )} -
- ) -} - -function PresetsButtonList({ presets, selectedInstanceId, selectedCategory, setInstanceAndCategory }) { - const doBack = useCallback( - () => setInstanceAndCategory([selectedInstanceId, null]), - [setInstanceAndCategory, selectedInstanceId] - ) - - const options = Object.values(presets).filter((p) => p.category === selectedCategory) - - return ( -
-
- - Back - - {selectedCategory} -
-

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

- - {options.map((preset, i) => { - return ( - - ) - })} - -
-
- ) -} - -function PresetIconPreview({ preset, instanceId, ...childProps }) { - const socket = useContext(SocketContext) - const [previewImage, setPreviewImage] = useState(null) - const [previewError, setPreviewError] = useState(false) - const [retryToken, setRetryToken] = useState(nanoid()) - - const [, drag] = useDrag({ - type: 'preset', - item: { - instanceId: instanceId, - presetId: preset.id, - }, - }) - - useEffect(() => { - setPreviewError(false) - - socketEmitPromise(socket, 'presets:preview_render', [instanceId, preset.id]) - .then((img) => { - setPreviewImage(img) - }) - .catch((e) => { - console.error('Failed to preview bank') - setPreviewError(true) - }) - }, [preset.id, socket, instanceId, retryToken]) - - const onClick = useCallback((_location, isDown) => isDown && setRetryToken(nanoid()), []) - - return ( - - ) -} diff --git a/webui/src/Buttons/Presets.tsx b/webui/src/Buttons/Presets.tsx new file mode 100644 index 0000000000..3515571d60 --- /dev/null +++ b/webui/src/Buttons/Presets.tsx @@ -0,0 +1,305 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { CAlert, CButton, CRow } from '@coreui/react' +import { + ConnectionsContext, + LoadingRetryOrError, + socketEmitPromise, + applyPatchOrReplaceSubObject, + SocketContext, + ModulesContext, +} from '../util' +import { useDrag } from 'react-dnd' +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' + +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< + [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()), []) + + // Reset selection on resetToken change + useEffect(() => { + setConnectionAndCategory([null, null]) + }, [resetToken]) + + useEffect(() => { + setPresetsMap(null) + setPresetError(null) + + socketEmitPromise(socket, 'presets:subscribe', []) + .then((data) => { + setPresetsMap(data) + }) + .catch((e) => { + console.error('Failed to load presets', e) + setPresetError('Failed to load presets') + }) + + const updatePresets = (id: string, patch: JsonPatchOperation[]) => { + setPresetsMap((oldPresets) => + oldPresets + ? applyPatchOrReplaceSubObject | undefined>(oldPresets, id, patch, {}) + : null + ) + } + + socket.on('presets:update', updatePresets) + + return () => { + socket.off('presets:update', updatePresets) + + socketEmitPromise(socket, 'presets:unsubscribe', []).catch(() => { + console.error('Failed to unsubscribe to presets') + }) + } + }, [socket, reloadToken]) + + if (!presetsMap) { + // Show loading or an error + return ( + + + + ) + } + + if (connectionAndCategory[0]) { + const connectionInfo = connectionsContext[connectionAndCategory[0]] + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined + + const presets = presetsMap[connectionAndCategory[0]] ?? {} + + if (connectionAndCategory[1]) { + return ( + + ) + } else { + return ( + + ) + } + } else { + return + } +} + +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) + + const options = Object.entries(presets).map(([id, vals]) => { + if (!vals || Object.values(vals).length === 0) return '' + + const connectionInfo = connectionsContext[id] + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined + + return ( +
+ setConnectionAndCategory([id, null])} + > + {moduleInfo?.name ?? '?'} ({connectionInfo?.label ?? id}) + +
+ ) + }) + + return ( +
+
Presets
+

+ 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. + ) : ( + options + )} +
+ ) +} + +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) + } + + const doBack = useCallback(() => setConnectionAndCategory([null, null]), [setConnectionAndCategory]) + + const buttons = Array.from(categories).map((category) => { + return ( + setConnectionAndCategory([selectedConnectionId, category])} + > + {category} + + ) + }) + + return ( +
+
+ + Back + + {moduleInfo?.name ?? '?'} ({connectionInfo?.label ?? selectedConnectionId}) +
+ + {buttons.length === 0 ? ( + Connection has no presets. + ) : ( +
{buttons}
+ )} +
+ ) +} + +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 filteredPresets = Object.values(presets).filter((p) => p.category === selectedCategory) + + return ( +
+
+ + Back + + {selectedCategory} +
+

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

+ + {filteredPresets.map((preset, i) => { + return ( + + ) + })} + +
+
+ ) +} + +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({ + type: 'preset', + item: { + connectionId: connectionId, + presetId: presetId, + }, + }) + + useEffect(() => { + setPreviewError(false) + + socketEmitPromise(socket, 'presets:preview_render', [connectionId, presetId]) + .then((img) => { + setPreviewImage(img) + }) + .catch(() => { + console.error('Failed to preview control') + setPreviewError(true) + }) + }, [presetId, socket, connectionId, retryToken]) + + 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.jsx deleted file mode 100644 index b3a87b894b..0000000000 --- a/webui/src/Buttons/Variables.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { CButton } from '@coreui/react' -import { InstancesContext, VariableDefinitionsContext, ModulesContext } from '../util' -import { VariablesTable } from '../Components/VariablesTable' -import { CustomVariablesList } from './CustomVariablesList' - -export const InstanceVariables = function InstanceVariables({ resetToken }) { - const instancesContext = useContext(InstancesContext) - - 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)) { - labelMap.set(instance.label, id) - } - return labelMap - }, [instancesContext]) - - // Reset selection on resetToken change - useEffect(() => { - setInstance(null) - }, [resetToken]) - - if (showCustom) { - return - } else if (instanceId) { - let instanceLabel = instancesContext[instanceId]?.label - if (instanceId === 'internal') instanceLabel = 'internal' - - return - } else { - return ( - - ) - } -} - -function VariablesInstanceList({ setInstance, setShowCustom, instancesLabelMap }) { - const modules = useContext(ModulesContext) - const instancesContext = useContext(InstancesContext) - const variableDefinitionsContext = useContext(VariableDefinitionsContext) - - const options = Object.entries(variableDefinitionsContext || []).map(([label, defs]) => { - if (!defs || Object.keys(defs).length === 0) return '' - - if (label === 'internal') { - return ( -
- setInstance('internal')}> - Internal - -
- ) - } - - const id = instancesLabelMap.get(label) - const instance = id ? instancesContext[id] : undefined - const module = instance ? modules[instance.instance_type] : undefined - - return ( -
- setInstance(id)}> - {module?.name ?? module?.name ?? '?'} ({label ?? id}) - -
- ) - }) - - return ( -
-
Variables
-

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

-
- setShowCustom(true)}> - Custom Variables - -
- {options} -
- ) -} - -function VariablesList({ selectedInstanceLabel, setInstance }) { - const doBack = useCallback(() => setInstance(null), [setInstance]) - - return ( -
-
- - Back - - Variables for {selectedInstanceLabel} -
- - - -
-
- ) -} diff --git a/webui/src/Buttons/Variables.tsx b/webui/src/Buttons/Variables.tsx new file mode 100644 index 0000000000..4889481c85 --- /dev/null +++ b/webui/src/Buttons/Variables.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { CButton } from '@coreui/react' +import { ConnectionsContext, VariableDefinitionsContext, ModulesContext } from '../util' +import { VariablesTable } from '../Components/VariablesTable' +import { CustomVariablesList } from './CustomVariablesList' + +interface ConnectionVariablesProps { + resetToken: string +} + +export const ConnectionVariables = function ConnectionVariables({ resetToken }: ConnectionVariablesProps) { + const connectionsContext = useContext(ConnectionsContext) + + const [connectionId, setConnectionId] = useState(null) + const [showCustom, setShowCustom] = useState(false) + + const connectionsLabelMap: ReadonlyMap = useMemo(() => { + const labelMap = new Map() + for (const [connectionId, connectionInfo] of Object.entries(connectionsContext)) { + labelMap.set(connectionInfo.label, connectionId) + } + return labelMap + }, [connectionsContext]) + + // Reset selection on resetToken change + useEffect(() => { + setConnectionId(null) + }, [resetToken]) + + if (showCustom) { + return + } else if (connectionId) { + let connectionLabel = connectionsContext[connectionId]?.label + if (connectionId === 'internal') connectionLabel = 'internal' + + return + } else { + return ( + + ) + } +} + +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) + + const options = Object.entries(variableDefinitionsContext || []).map(([label, defs]) => { + if (!defs || Object.keys(defs).length === 0) return '' + + if (label === 'internal') { + return ( +
+ setConnectionId('internal')}> + Internal + +
+ ) + } + + const connectionId = connectionsLabelMap.get(label) + const connectionInfo = connectionId ? connectionsContext[connectionId] : undefined + const moduleInfo = connectionInfo ? modules[connectionInfo.instance_type] : undefined + + return ( +
+ setConnectionId(connectionId ?? null)} + > + {moduleInfo?.name ?? moduleInfo?.name ?? '?'} ({label ?? connectionId}) + +
+ ) + }) + + return ( +
+
Variables
+

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

+
+ setShowCustom(true)}> + Custom Variables + +
+ {options} +
+ ) +} + +interface VariablesListProps { + selectedConnectionLabel: string + setConnectionId: (connectionId: string | null) => void +} + +function VariablesList({ selectedConnectionLabel, setConnectionId }: VariablesListProps) { + const doBack = useCallback(() => setConnectionId(null), [setConnectionId]) + + return ( +
+
+ + Back + + Variables for {selectedConnectionLabel} +
+ + + +
+
+ ) +} diff --git a/webui/src/Buttons/index.jsx b/webui/src/Buttons/index.tsx similarity index 86% rename from webui/src/Buttons/index.jsx rename to webui/src/Buttons/index.tsx index b923db9066..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 { InstanceVariables } from './Variables' -import { useElementSize } from 'usehooks-ts' +import React, { memo, useCallback, useContext, useRef, useState } from 'react' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' +import { ConnectionVariables } from './Variables' 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 && ( @@ -254,7 +267,7 @@ export const ButtonsPage = memo(function ButtonsPage({ hotPress }) { - + 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 ef603708ed..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 = { - bank: 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 71% rename from webui/src/Components/DropdownInputField.jsx rename to webui/src/Components/DropdownInputField.tsx index 1ab8d4c0e5..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 } from 'react' +import React, { createContext, useContext, useMemo, useEffect, useCallback, memo } from 'react' import Select from 'react-select' -import CreatableSelect from 'react-select/creatable' +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 +} -export const MenuPortalContext = createContext(null) +interface DropdownChoiceInt { + value: any + label: DropdownChoiceId +} -export function DropdownInputField({ +export const DropdownInputField = memo(function DropdownInputField({ choices, allowCustom, minSelection, @@ -20,11 +41,11 @@ export 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 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 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 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 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 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 77% rename from webui/src/Components/TextInputField.jsx rename to webui/src/Components/TextInputField.tsx index bc1f80159b..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,11 +57,12 @@ export function TextInputField({ useEffect(() => { // Update the suggestions list in tribute whenever anything changes - const suggestions = [] + const suggestions: TributeSuggestion[] = [] 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}` + if (!va) continue + const variableId = `${connectionLabel}:${name}` suggestions.push({ key: variableId + ')', value: 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 {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 78% rename from webui/src/ConnectionDebug.jsx rename to webui/src/ConnectionDebug.tsx index 90a78e4191..39374dfb92 100644 --- a/webui/src/ConnectionDebug.jsx +++ b/webui/src/ConnectionDebug.tsx @@ -1,20 +1,38 @@ -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' +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', + message: 'Only recent lines are shown here, nothing is persisted', +} + export function ConnectionDebug() { const socket = useContext(SocketContext) 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()) @@ -43,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 }]) } @@ -55,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) }) @@ -87,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() @@ -94,23 +111,23 @@ 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]) - 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], @@ -193,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) @@ -207,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(() => { @@ -220,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 @@ -252,21 +276,23 @@ 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 = messages[index] + const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] useEffect(() => { if (rowRef.current) { @@ -282,14 +308,14 @@ function LogPanelContents({ linesBuffer, listChunkClearedToken, config, contentW ) } - const outerRef = useRef(null) + const outerRef = useRef(null) return ( {({ height, width }) => ( { +interface LogLineInnerProps { + h: DebugLogLine + innerRef: React.RefObject +} +const LogLineInner = memo(({ h, innerRef }: LogLineInnerProps) => { return (
{h.level !== 'console' && ( @@ -316,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/Instances/AddInstance.jsx b/webui/src/Connections/AddConnection.tsx similarity index 72% rename from webui/src/Instances/AddInstance.jsx rename to webui/src/Connections/AddConnection.tsx index b07452325a..ef8b3b1c4d 100644 --- a/webui/src/Instances/AddInstance.jsx +++ b/webui/src/Connections/AddConnection.tsx @@ -6,65 +6,74 @@ 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 AddInstancesPanel({ showHelp, doConfigureInstance }) { +interface AddConnectionsPanelProps { + showHelp: (moduleId: string) => void + doConfigureConnection: (connectionId: string) => void +} + +export function AddConnectionsPanel({ showHelp, doConfigureConnection }: AddConnectionsPanelProps) { return ( <> - + ) } -const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureInstance }) { +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 addInstanceInner = useCallback( - (type, product) => { - socketEmitPromise(socket, 'instances:add', [{ type: type, product: product }]) + const addConnectionInner = useCallback( + (type: string, product: string | undefined) => { + socketEmitPromise(socket, 'connections:add', [{ type: type, product: product }]) .then((id) => { setFilter('') - console.log('NEW INSTANCE', id) - configureInstance(id) + console.log('NEW CONNECTION', 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, configureInstance] + [socket, notifier, doConfigureConnection] ) - const addInstance = useCallback( - (type, product, module) => { + const addConnection = useCallback( + (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', () => { - addInstanceInner(type, product) + addConnectionInner(type, product) } ) } else { - addInstanceInner(type, product) + addConnectionInner(type, product) } }, - [addInstanceInner] + [addConnectionInner] ) const allProducts = useMemo(() => { 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, { @@ -76,7 +85,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 +179,7 @@ const AddInstancesInner = memo(function AddInstancesInner({ showHelp, configureI
-
{candidates}
+
{candidates}
) }) diff --git a/webui/src/Instances/InstanceEditPanel.jsx b/webui/src/Connections/ConnectionEditPanel.tsx similarity index 56% rename from webui/src/Instances/InstanceEditPanel.jsx rename to webui/src/Connections/ConnectionEditPanel.tsx index a2b5e263ea..2ee9faed8e 100644 --- a/webui/src/Instances/InstanceEditPanel.jsx +++ b/webui/src/Connections/ConnectionEditPanel.tsx @@ -1,21 +1,35 @@ -import React, { memo, useCallback, useContext, useEffect, useState } from 'react' -import { LoadingRetryOrError, sandbox, socketEmitPromise, SocketContext, ModulesContext } from '../util' +import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react' +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' -export function InstanceEditPanel({ instanceId, instanceStatus, doConfigureInstance, showHelp }) { - console.log('status', instanceStatus) +interface ConnectionEditPanelProps { + connectionId: string + connectionStatus: ConnectionStatusEntry | undefined + doConfigureConnection: (connectionId: string | null) => void + showHelp: (moduleId: string) => void +} + +export function ConnectionEditPanel({ + connectionId, + connectionStatus, + doConfigureConnection, + showHelp, +}: ConnectionEditPanelProps) { + 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,42 +39,69 @@ export function InstanceEditPanel({ instanceId, instanceStatus, doConfigureInsta } return ( - + ) } -const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doConfigureInstance, showHelp }) { +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 [instanceConfig, setInstanceConfig] = useState(null) - const [instanceLabel, setInstanceLabel] = useState(null) - const [instanceType, setInstanceType] = 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 invalidFieldNames = useMemo(() => { + const fieldNames: string[] = [] + + if (validFields) { + for (const [field, valid] of Object.entries(validFields)) { + if (!valid && fieldVisibility[field] !== false) { + fieldNames.push(field) + } + } + } - const [fieldVisibility, setFieldVisibility] = useState({}) + return fieldNames + }, [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() - 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 (!newLabel || !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') { @@ -78,14 +119,14 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC .catch((e) => { setError(`Failed to save connection config: ${e}`) }) - }, [socket, instanceId, validFields, 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 = {} + const validFields: Record = {} for (const field of res.fields) { // Real validation status gets generated when the editor components first mount validFields[field.id] = true @@ -97,9 +138,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`) @@ -113,23 +154,23 @@ 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) => { + const setValue = useCallback((key: string, value: any) => { console.log('set value', key, value) - setInstanceConfig((oldConfig) => ({ + setConnectionConfig((oldConfig) => ({ ...oldConfig, [key]: value, })) }, []) - const setValid = useCallback((key, isValid) => { + const setValid = useCallback((key: string, isValid: boolean) => { console.log('set valid', key, isValid) setValidFields((oldValid) => ({ @@ -139,14 +180,14 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC }, []) useEffect(() => { - const visibility = {} + const visibility: Record = {} - 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) } } @@ -155,27 +196,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 = connectionType ? modules[connectionType] : undefined + const dataReady = !!connectionConfig && !!configFields && !!validFields return (
- {moduleInfo?.shortname ?? instanceType} configuration + {moduleInfo?.shortname ?? connectionType} configuration {moduleInfo?.hasHelp && ( -
showHelp(instanceType)}> +
connectionType && showHelp(connectionType)}>
)}
- + - {instanceId && dataReady && ( + {connectionId && dataReady && ( <> - + {configFields.map((field, i) => { @@ -184,7 +229,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC 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 && ( @@ -192,11 +237,11 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC )} ) @@ -210,7 +255,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC !v) === false || !isLabelValid(instanceLabel) + !validFields || invalidFieldNames.length > 0 || !connectionLabel || !isLabelValid(connectionLabel) } onClick={doSave} > @@ -226,11 +271,20 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC ) }) -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 = { @@ -297,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/Instances/InstanceList.jsx b/webui/src/Connections/ConnectionList.tsx similarity index 62% rename from webui/src/Instances/InstanceList.jsx rename to webui/src/Connections/ConnectionList.tsx index a8b22b433f..253e0f86e2 100644 --- a/webui/src/Instances/InstanceList.jsx +++ b/webui/src/Connections/ConnectionList.tsx @@ -1,6 +1,12 @@ -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 { InstancesContext, VariableDefinitionsContext, socketEmitPromise, SocketContext, ModulesContext } from '../util' +import { + ConnectionsContext, + VariableDefinitionsContext, + socketEmitPromise, + SocketContext, + ModulesContext, +} from '../util.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDollarSign, @@ -14,37 +20,57 @@ import { faEyeSlash, } from '@fortawesome/free-solid-svg-icons' -import { InstanceVariablesModal } from './InstanceVariablesModal' -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' + +interface VisibleConnectionsState { + disabled: boolean + ok: boolean + warning: boolean + error: boolean +} -export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, selectedInstanceId }) { +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 instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) - const instancesRef = useRef(null) + const connectionsRef = useRef>() useEffect(() => { - instancesRef.current = instancesContext - }, [instancesContext]) + connectionsRef.current = connectionsContext + }, [connectionsContext]) - const deleteModalRef = useRef() - const variablesModalRef = useRef() + const deleteModalRef = useRef(null) + const variablesModalRef = useRef(null) - const doShowVariables = useCallback((instanceId) => { - variablesModalRef.current.show(instanceId) + 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], @@ -58,8 +84,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 +96,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 +106,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 +126,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 +152,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s

- + @@ -183,7 +209,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s )} - {Object.keys(instancesContext).length === 0 && ( + {Object.keys(connectionsContext).length === 0 && ( - + @@ -287,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, @@ -294,7 +394,6 @@ function ActionTableRow({ location, index, dragId, - controlId, setValue, doDelete, doDuplicate, @@ -305,24 +404,27 @@ function ActionTableRow({ readonly, isCollapsed, setCollapsed, -}) { - const instancesContext = useContext(InstancesContext) +}: 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 } @@ -344,7 +446,7 @@ function ActionTableRow({ item.setId = setId }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: dragId, canDrag: !readonly, item: { @@ -360,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]) @@ -408,23 +471,18 @@ function ActionTableRow({ if (!action) { // Invalid action, so skip - return '' + return null } - const instance = instancesContext[action.instance] + const connectionInfo = connectionsContext[action.instance] // const module = instance ? modules[instance.instance_type] : undefined - const instanceLabel = instance?.label ?? action.instance - - const options = actionSpec?.options ?? [] + const connectionLabel = connectionInfo?.label ?? action.instance const showButtonPreview = action?.instance === 'internal' && actionSpec?.showButtonPreview - let name = '' - if (actionSpec) { - name = `${instanceLabel}: ${actionSpec.label}` - } else { - name = `${instanceLabel}: ${action.action} (undefined)` - } + const name = actionSpec + ? `${connectionLabel}: ${actionSpec.label}` + : `${connectionLabel}: ${action.action} (undefined)` return ( @@ -452,7 +510,7 @@ function ActionTableRow({ - {doEnabled && ( + {!!doEnabled && ( <>   {actionSpec?.description || ''} {location && showButtonPreview && ( -
+
)} @@ -505,13 +563,13 @@ function ActionTableRow({
- {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 { @@ -539,7 +597,7 @@ const filterOptions = (candidate, input) => { } } -const noOptionsMessage = ({ inputValue }) => { +const noOptionsMessage = ({ inputValue }: { inputValue: string }) => { if (inputValue) { return 'No actions found' } else { @@ -547,36 +605,52 @@ 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 instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) const actionsContext = useContext(ActionsContext) const options = useMemo(() => { - const options = [] - for (const [instanceId, instanceActions] of Object.entries(actionsContext)) { - for (const [actionId, action] of Object.entries(instanceActions || {})) { - const instanceLabel = instancesContext[instanceId]?.label ?? instanceId + 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, - value: `${instanceId}:${actionId}`, - label: `${instanceLabel}: ${action.label}`, + value: `${connectionId}:${actionId}`, + label: `${connectionLabel}: ${action.label}`, }) } } - const recents = [] - for (const actionType of recentActionsContext.recentActions) { + const recents: AddActionOption[] = [] + 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 instanceLabel = instancesContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId recents.push({ isRecent: true, - value: `${instanceId}:${actionId}`, - label: `${instanceLabel}: ${actionInfo.label}`, + value: `${connectionId}:${actionId}`, + label: `${connectionLabel}: ${actionInfo.label}`, }) } } @@ -587,12 +661,12 @@ function AddActionDropdown({ onSelect, placeholder }) { }) return options - }, [actionsContext, instancesContext, 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 62% rename from webui/src/Controls/AddModal.jsx rename to webui/src/Controls/AddModal.tsx index df9234bcbf..e0d125fb7e 100644 --- a/webui/src/Controls/AddModal.jsx +++ b/webui/src/Controls/AddModal.tsx @@ -14,15 +14,26 @@ import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMem import { ActionsContext, FeedbacksContext, - InstancesContext, + ConnectionsContext, 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 instances = useContext(InstancesContext) + const connections = useContext(ConnectionsContext) const [show, setShow] = useState(false) @@ -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) }, @@ -78,16 +89,16 @@ export const AddActionsModal = forwardRef(function AddActionsModal({ addAction } /> - {Object.entries(actions).map(([instanceId, items]) => ( - ( + ))} @@ -101,10 +112,21 @@ 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 instances = useContext(InstancesContext) + const connections = useContext(ConnectionsContext) const [show, setShow] = useState(false) @@ -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) }, @@ -159,17 +181,17 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed /> - {Object.entries(feedbacks).map(([instanceId, items]) => ( - ( + ))} @@ -183,9 +205,21 @@ export const AddFeedbacksModal = forwardRef(function AddFeedbacksModal({ addFeed ) }) -function InstanceCollapse({ - instanceId, - instanceInfo, +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, items, itemName, expanded, @@ -193,19 +227,19 @@ function InstanceCollapse({ booleanOnly, doToggle, doAdd, -}) { - const doToggle2 = useCallback(() => doToggle(instanceId), [doToggle, instanceId]) +}: ConnectionCollapseProps) { + const doToggle2 = useCallback(() => doToggle(connectionId), [doToggle, connectionId]) const candidates = useMemo(() => { try { 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 = `${instanceId}:${id}` + const fullId = `${connectionId}:${id}` res.push({ ...info, fullId: fullId, @@ -228,16 +262,16 @@ function InstanceCollapse({ ) } - }, [items, filter, instanceId, itemName, booleanOnly]) + }, [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 (
- {instanceInfo?.label || instanceId} + {connectionInfo?.label || connectionId}
@@ -261,7 +295,12 @@ function InstanceCollapse({ } } -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 72% rename from webui/src/Controls/ButtonStyleConfig.jsx rename to webui/src/Controls/ButtonStyleConfig.tsx index 24f8774851..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 { socketEmitPromise, SocketContext, UserConfigContext, PreventDefaultHandler } from '../util' +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 } 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' +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,23 +141,18 @@ 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] ) - 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( @@ -135,7 +160,7 @@ export function ButtonStyleConfigFields({ [mainDialog] ) - const showField2 = (id) => !showField || showField(id) + const showField2 = (id: string) => !showField || showField(id) return ( <> @@ -188,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} />
@@ -197,13 +223,13 @@ export function ButtonStyleConfigFields({ {showField2('color') && (
- +
)} {showField2('bgcolor') && (
- +
)} @@ -212,13 +238,10 @@ export function ButtonStyleConfigFields({
)} @@ -246,7 +269,7 @@ export function ButtonStyleConfigFields({ {showField2('png64') && (
// 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 69% rename from webui/src/Controls/FeedbackEditor.jsx rename to webui/src/Controls/FeedbackEditor.tsx index 7b7877d35f..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, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useMemo, useRef, useState } from 'react' import { FeedbacksContext, - InstancesContext, + 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,17 +121,17 @@ export function ControlFeedbacksEditor({ ) const addFeedback = useCallback( - (feedbackType) => { - const [instanceId, feedbackId] = feedbackType.split(':', 2) - socketEmitPromise(socket, 'controls:feedback:add', [controlId, instanceId, feedbackId]).catch((e) => { - console.error('Failed to add bank feedback', e) + (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) }) }, [socket, controlId] ) 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,57 +409,22 @@ function FeedbackEditor({ doExpand, doEnabled, booleanOnly, -}) { +}: FeedbackEditorProps) { const feedbacksContext = useContext(FeedbacksContext) - const instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) - const instance = instancesContext[feedback.instance_id] - const instanceLabel = instance?.label ?? feedback.instance_id + const connectionInfo = connectionsContext[feedback.instance_id] + 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 = `${instanceLabel}: ${feedbackSpec.label}` - } else { - name = `${instanceLabel}: ${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 && ( <>   {feedbackSpec?.description || ''}
{location && showButtonPreview && ( -
+
)} @@ -462,12 +485,13 @@ function FeedbackEditor({
- {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,31 +571,30 @@ 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) + }, [setStylePropsValue]) const currentStyle = useMemo(() => feedback?.style || {}, [feedback?.style]) const showField = useCallback((id) => id in currentStyle, [currentStyle]) @@ -582,8 +611,9 @@ function FeedbackStyles({ feedbackSpec, feedback, setStylePropsValue }) { @@ -592,12 +622,12 @@ 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 { @@ -605,7 +635,7 @@ const filterOptions = (candidate, input) => { } } -const noOptionsMessage = ({ inputValue }) => { +const noOptionsMessage = ({ inputValue }: { inputValue: string }) => { if (inputValue) { return 'No feedbacks found' } else { @@ -613,38 +643,59 @@ const noOptionsMessage = ({ inputValue }) => { } } -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 instancesContext = useContext(InstancesContext) + const connectionsContext = useContext(ConnectionsContext) const options = useMemo(() => { - const options = [] - for (const [instanceId, instanceFeedbacks] of Object.entries(feedbacksContext)) { + 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 instanceLabel = instancesContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId options.push({ isRecent: false, - value: `${instanceId}:${feedbackId}`, - label: `${instanceLabel}: ${feedback.label}`, + value: `${connectionId}:${feedbackId}`, + label: `${connectionLabel}: ${feedback.label}`, }) } } } - const recents = [] - for (const feedbackType of recentFeedbacksContext.recentFeedbacks || []) { + const recents: AddFeedbackOption[] = [] + 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 instanceLabel = instancesContext[instanceId]?.label ?? instanceId + const connectionLabel = connectionsContext[connectionId]?.label ?? connectionId recents.push({ isRecent: true, - value: `${instanceId}:${feedbackId}`, - label: `${instanceLabel}: ${feedbackInfo.label}`, + value: `${connectionId}:${feedbackId}`, + label: `${connectionLabel}: ${feedbackInfo.label}`, }) } } @@ -655,12 +706,12 @@ function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { }) return options - }, [feedbacksContext, instancesContext, 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) } @@ -686,4 +737,4 @@ function AddFeedbackDropdown({ onSelect, booleanOnly, addPlaceholder }) { noOptionsMessage={noOptionsMessage} /> ) -} +}) diff --git a/webui/src/Controls/InternalInstanceFields.jsx b/webui/src/Controls/InternalInstanceFields.tsx similarity index 60% rename from webui/src/Controls/InternalInstanceFields.jsx rename to webui/src/Controls/InternalInstanceFields.tsx index d94b542f56..b159f9920b 100644 --- a/webui/src/Controls/InternalInstanceFields.jsx +++ b/webui/src/Controls/InternalInstanceFields.tsx @@ -2,15 +2,23 @@ import React, { useContext, useMemo } from 'react' import { DropdownInputField } from '../Components' import { CustomVariableDefinitionsContext, - InstancesContext, + ConnectionsContext, PagesContext, SurfacesContext, TriggersContext, 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 ( @@ -52,6 +60,7 @@ export function InternalInstanceField(option, isOnControl, readonly, value, setV value={value} setValue={setValue} includeSelf={option.includeSelf} + useRawSurfaces={option.useRawSurfaces} /> ) case 'internal:trigger': @@ -68,12 +77,28 @@ export function InternalInstanceField(option, isOnControl, readonly, value, setV return default: // Use fallback - return undefined + return null } } -function InternalInstanceIdDropdown({ includeAll, value, setValue, disabled, multiple, filterActionsRecorder }) { - const context = useContext(InstancesContext) +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(() => { const instance_choices = [] @@ -94,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' }) } @@ -107,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]) @@ -116,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 = [] @@ -149,14 +194,21 @@ 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 [instanceLabel, variables] of Object.entries(context)) { + for (const [connectionLabel, variables] of Object.entries(context)) { for (const [name, variable] of Object.entries(variables || {})) { - const id = `${instanceLabel}:${name}` + if (!variable) continue + const id = `${connectionLabel}:${name}` choices.push({ id, label: `${variable.label} (${id})`, @@ -181,35 +233,74 @@ function InternalVariableDropdown({ value, setValue, disabled }) { ) } -function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf }) { - const context = useContext(SurfacesContext) +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' }) } - 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 Object.values(surfacesContext ?? {})) { + if (!group) continue - for (const surface of Object.values(context?.offline ?? {})) { - choices.push({ - label: `${surface.name || surface.type} (${surface.id}) - Offline`, - id: surface.id, - }) + choices.push({ + label: group.displayName, + id: group.id, + }) + } + } else { + for (const group of Object.values(surfacesContext ?? {})) { + if (!group) continue + + for (const surface of group.surfaces) { + choices.push({ + label: surface.displayName, + id: surface.id, + }) + } + } } + return choices - }, [context, isOnControl, includeSelf]) + }, [surfacesContext, isOnControl, includeSelf, useRawSurfaces]) 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(() => { @@ -219,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}`, @@ -230,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 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 */ -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 72% rename from webui/src/Controls/OptionsInputField.jsx rename to webui/src/Controls/OptionsInputField.tsx index ba41fc240c..c8b85a3eb3 100644 --- a/webui/src/Controls/OptionsInputField.jsx +++ b/webui/src/Controls/OptionsInputField.tsx @@ -6,13 +6,27 @@ 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({ - instanceId, + connectionId, isOnControl, isAction, actionId, @@ -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 = ( @@ -39,7 +53,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} /> @@ -124,15 +138,15 @@ export function OptionsInputField({ case 'custom-variable': { if (isAction) { control = ( - + ) } break } 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') { - control = InternalInstanceField(option, isOnControl, readonly, value, setValue2) + if (connectionId === 'internal') { + 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 51% rename from webui/src/Emulator/Emulator.jsx rename to webui/src/Emulator/Emulator.tsx index 6333d5821d..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,25 +7,28 @@ import { socketEmitPromise, useMountEffect, PreventDefaultHandler, -} from '../util' -import { CButton, CCol, CContainer, CForm, CRow } from '@coreui/react' +} 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 { formatLocation } from '@companion/shared/ControlId' +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({}) @@ -41,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) @@ -66,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 @@ -101,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) @@ -136,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) } @@ -166,30 +171,32 @@ export function Emulator() { }, []) return ( -
- - {config ? ( - <> - - - - - ) : ( - - - - )} - +
+ {config ? ( + <> + + + + + ) : ( + + + + )}
) } -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) @@ -229,18 +236,19 @@ function ConfigurePanel({ config }) { - ) : ( - '' - ) + ) : null } -// function clamp(val, max) { -// return Math.min(Math.max(0, val), max) -// } +interface EmulatorButtonsProps { + imageCache: EmulatorImageCache + setKeyDown: (location: ControlLocation | null) => void + columns: number + rows: number +} -function CyclePages({ imageCache, setKeyDown, columns, rows }) { - const bankClick = useCallback( - (location, pressed) => { +function EmulatorButtons({ imageCache, setKeyDown, columns, rows }: EmulatorButtonsProps) { + const buttonClick = useCallback( + (location: ControlLocation, pressed: boolean) => { if (pressed) { setKeyDown(location) } else { @@ -250,61 +258,44 @@ 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} +
- +
) } -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 63% rename from webui/src/Emulator/List.jsx rename to webui/src/Emulator/List.tsx index 73e78a1990..4a1ac93f86 100644 --- a/webui/src/Emulator/List.jsx +++ b/webui/src/Emulator/List.tsx @@ -1,56 +1,69 @@ -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 [surfaces, setSurfaces] = 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()), []) 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) setLoadError('Failed to load surfaces') }) - const patchSurfaces = (patch) => { - setSurfaces((oldSurfaces) => { - return jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument + const patchSurfaces = (patch: JsonPatchOperation[]) => { + setSurfaceGroups((oldSurfaces) => { + 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(() => { - return Object.values(surfaces?.available || {}).filter((s) => s.id.startsWith('emulator:')) - }, [surfaces]) + const emulators: ClientSurfaceItem[] = [] + + 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 && (

 

@@ -82,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 bc56ed03ce..0000000000 --- a/webui/src/ImportExport/ExportFormat.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { DropdownInputField } from '../Components/DropdownInputField' - -export const ExportFormatDefault = 'json-gz' -const formatOptions = [ - { - id: 'json-gz', - label: 'Compressed', - }, - { - id: 'json', - label: 'Uncompressed', - }, -] - -export 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 69% rename from webui/src/ImportExport/Import/Page.jsx rename to webui/src/ImportExport/Import/Page.tsx index cdfd6a69fa..6c9caa38c0 100644 --- a/webui/src/ImportExport/Import/Page.jsx +++ b/webui/src/ImportExport/Import/Page.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { CButton, CCol, CRow, CSelect } from '@coreui/react' import { - InstancesContext, + ConnectionsContext, ModulesContext, MyErrorBoundary, PagesContext, @@ -11,12 +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' + +interface ImportPageWizardProps { + snapshot: ClientImportObject + instanceRemap: Record + setInstanceRemap: React.Dispatch>> + doImport: (importPageNumber: number, pageNumber: number, instanceRemap: Record) => void +} -export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, doImport }) { +export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, doImport }: ImportPageWizardProps) { const pages = useContext(PagesContext) const userConfig = useContext(UserConfigContext) @@ -30,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, @@ -43,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]) @@ -81,16 +96,16 @@ export function ImportPageWizard({ snapshot, instanceRemap, setInstanceRemap, do -
- {hasBeenRendered && ( +
+ {hasBeenRendered && sourceGridSize && ( @@ -118,8 +133,8 @@ 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 instancesContext = useContext(InstancesContext) + 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 (
@@ -166,22 +195,25 @@ 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(instancesContext).filter( - ([id, inst]) => inst.instance_type === instance.instance_type + const currentInstances = Object.entries(connectionsContext).filter( + ([_id, inst]) => inst.instance_type === instance.instance_type ) return ( - - - - - - - - ) -} - -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]) - - return ( - - - - - - - ) -} diff --git a/webui/src/Surfaces/index.tsx b/webui/src/Surfaces/index.tsx new file mode 100644 index 0000000000..4e50e40d10 --- /dev/null +++ b/webui/src/Surfaces/index.tsx @@ -0,0 +1,320 @@ +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 { 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 editModalRef = useRef(null) + const addGroupModalRef = useRef(null) + const confirmRef = useRef(null) + + const [scanning, setScanning] = useState(false) + const [scanError, setScanError] = useState(null) + + const refreshUSB = useCallback(() => { + setScanning(true) + setScanError(null) + + socketEmitPromise(socket, 'surfaces:rescan', [], 30000) + .then((errorMsg) => { + setScanError(errorMsg || null) + setScanning(false) + }) + .catch((err) => { + console.error('Refresh USB failed', err) + + setScanning(false) + }) + }, [socket]) + + const addEmulator = useCallback(() => { + socketEmitPromise(socket, 'surfaces:emulator-add', []).catch((err) => { + console.error('Emulator add failed', err) + }) + }, [socket]) + + const deleteEmulator = useCallback( + (surfaceId) => { + confirmRef?.current?.show('Remove Emulator', 'Are you sure?', 'Remove', () => { + socketEmitPromise(socket, 'surfaces:emulator-remove', [surfaceId]).catch((err) => { + console.error('Emulator remove failed', err) + }) + }) + }, + [socket] + ) + + 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: string) => { + editModalRef.current?.show(surfaceId, null) + }, []) + + const configureGroup = useCallback((groupId: string) => { + editModalRef.current?.show(null, groupId) + }, []) + + const forgetSurface = useCallback( + (surfaceId) => { + confirmRef.current?.show( + 'Forget Surface', + 'Are you sure you want to forget this surface? Any settings will be lost', + 'Forget', + () => { + socketEmitPromise(socket, 'surfaces:forget', [surfaceId]).catch((err) => { + console.error('fotget failed', err) + }) + } + ) + }, + [socket] + ) + + const updateName = useCallback( + (surfaceId, name) => { + socketEmitPromise(socket, 'surfaces:set-name', [surfaceId, name]).catch((err) => { + console.error('Update name failed', err) + }) + }, + [socket] + ) + + const surfacesList = Object.values(surfacesContext).filter((grp): grp is ClientDevicesListItem => !!grp) + + return ( +
You haven't setup any connections yet.
@@ -198,7 +224,7 @@ export function InstancesList({ showHelp, doConfigureInstance, instanceStatus, s ) } -function loadVisibility() { +function loadVisibility(): VisibleConnectionsState { try { const rawConfig = window.localStorage.getItem('connections_visible') if (rawConfig !== null) { @@ -207,7 +233,7 @@ function loadVisibility() { } catch (e) {} // setup defaults - const config = { + const config: VisibleConnectionsState = { disabled: true, ok: true, warning: true, @@ -219,53 +245,72 @@ function loadVisibility() { return config } -function InstancesTableRow({ +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, - instance, - instanceStatus, + connection, + connectionStatus, showHelp, showVariables, - configureInstance, + configureConnection, deleteModalRef, moveRow, isSelected, -}) { +}: ConnectionsTableRowProps) { const socket = useContext(SocketContext) 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( + 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({ + const [, drop] = useDrop({ accept: 'connection', - hover(item, monitor) { + hover(item, _monitor) { if (!ref.current) { return } @@ -278,7 +323,7 @@ function InstancesTableRow({ moveRow(item.id, id) }, }) - const [{ isDragging }, drag, preview] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: 'connection', item: { id, @@ -289,30 +334,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 +377,10 @@ function InstancesTableRow({ {moduleInfo?.manufacturer ?? ''} ) : ( - instance.instance_type + connection.instance_type )}
@@ -351,7 +396,7 @@ function InstancesTableRow({ windowLinkOpen({ href: moduleInfo?.bugUrl })} + onClick={() => windowLinkOpen({ href: moduleInfo?.bugUrl })} size="md" title="Issue Tracker" disabled={!moduleInfo?.bugUrl} @@ -366,15 +411,16 @@ 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)} > 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} @@ -403,7 +449,12 @@ function InstancesTableRow({ ) } -function ModuleStatusCall({ isEnabled, status }) { +interface ModuleStatusCallProps { + isEnabled: boolean + status: ConnectionStatusEntry | undefined +} + +function ModuleStatusCall({ isEnabled, status }: ModuleStatusCallProps) { if (isEnabled) { const messageStr = !!status && @@ -420,7 +471,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) case 'warning': return ( -
+ {status.level || 'Warning'}
{messageStr} @@ -428,7 +479,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) case 'error': return ( -
+ {status.level || 'ERROR'}
{messageStr} @@ -436,7 +487,7 @@ function ModuleStatusCall({ isEnabled, status }) { ) default: return ( -
+ Unknown
{messageStr} 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/Instances/HelpModal.jsx b/webui/src/Connections/HelpModal.tsx similarity index 71% rename from webui/src/Instances/HelpModal.jsx rename to webui/src/Connections/HelpModal.tsx index 1d8eee18de..2dcde3bd97 100644 --- a/webui/src/Instances/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.tsx b/webui/src/Connections/index.tsx new file mode 100644 index 0000000000..4873b26a66 --- /dev/null +++ b/webui/src/Connections/index.tsx @@ -0,0 +1,131 @@ +import { CCol, CRow, CTabs, CTabContent, CTabPane, CNavItem, CNavLink, CNav } from '@coreui/react' +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' +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, { 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(null) + + const [tabResetToken, setTabResetToken] = useState(nanoid()) + const [activeTab, setActiveTab] = useState('add') + const [selectedConnectionId, setSelectedConnectionId] = useState(null) + const doChangeTab = useCallback((newTab: string) => { + setActiveTab((oldTab) => { + if (oldTab !== newTab) { + setSelectedConnectionId(null) + setTabResetToken(nanoid()) + } + return newTab + }) + }, []) + + const showHelp = useCallback( + (id: string) => { + socketEmitPromise(socket, 'connections:get-help', [id]).then(([err, result]) => { + if (err) { + notifier.current?.show('Instance help', `Failed to get help text: ${err}`) + return + } + if (result) { + helpModalRef.current?.show(id, result) + } + }) + }, + [socket, notifier] + ) + + const doConfigureConnection = useCallback((connectionId: string | null) => { + setSelectedConnectionId(connectionId) + setTabResetToken(nanoid()) + setActiveTab(connectionId ? 'edit' : 'add') + }, []) + + const [connectionStatus, setConnectionStatus] = useState>({}) + useEffect(() => { + socketEmitPromise(socket, 'connections:get-statuses', []) + .then((statuses) => { + setConnectionStatus(statuses) + }) + .catch((e) => { + console.error(`Failed to load connection statuses`, e) + }) + + const patchStatuses = (patch: JsonPatchOperation[]) => { + setConnectionStatus((oldStatuses) => { + if (!oldStatuses) return oldStatuses + return jsonPatch.applyPatch(cloneDeep(oldStatuses) || {}, patch).newDocument + }) + } + socket.on('connections:patch-statuses', patchStatuses) + + return () => { + socket.off('connections:patch-statuses', patchStatuses) + } + }, [socket]) + + return ( + + + + + + + + +
+ + + + + Add connection + + + + + + + + + + + + + {selectedConnectionId && ( + + )} + + + + +
+
+
+ ) +}) diff --git a/webui/src/Constants.js b/webui/src/Constants.js deleted file mode 100644 index 37e70da406..0000000000 --- a/webui/src/Constants.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Buffer } from 'buffer' - -// Hack for csv library which needs a global 'Buffer' -window.Buffer = Buffer - -export const FONT_SIZES = [ - { id: 'auto', label: 'Auto' }, - { id: '7', label: '7pt' }, - { id: '14', label: '14pt' }, - { id: '18', label: '18pt' }, - { id: '24', label: '24pt' }, - { id: '30', label: '30pt' }, - { id: '44', label: '44pt' }, -] - -export const PRIMARY_COLOR = '#d50215' diff --git a/webui/src/Constants.ts b/webui/src/Constants.ts new file mode 100644 index 0000000000..4831636548 --- /dev/null +++ b/webui/src/Constants.ts @@ -0,0 +1,23 @@ +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: DropdownChoice[] = [ + { id: 'auto', label: 'Auto' }, + { id: '7', label: '7pt' }, + { id: '14', label: '14pt' }, + { id: '18', label: '18pt' }, + { id: '24', label: '24pt' }, + { id: '30', label: '30pt' }, + { id: '44', label: '44pt' }, +] + +export const SHOW_HIDE_TOP_BAR: DropdownChoice[] = [ + { id: 'default', label: 'Follow Default' }, + { id: true as any, label: 'Show' }, + { id: false as any, label: 'Hide' }, +] + +export const PRIMARY_COLOR: string = '#d50215' diff --git a/webui/src/ContextData.jsx b/webui/src/ContextData.tsx similarity index 61% rename from webui/src/ContextData.jsx rename to webui/src/ContextData.tsx index fd3906a527..58936b1707 100644 --- a/webui/src/ContextData.jsx +++ b/webui/src/ContextData.tsx @@ -3,7 +3,7 @@ import { applyPatchOrReplaceSubObject, ActionsContext, FeedbacksContext, - InstancesContext, + ConnectionsContext, VariableDefinitionsContext, CustomVariableDefinitionsContext, UserConfigContext, @@ -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, 'instances:subscribe', []) - .then((instances) => { - setInstances(instances) + socketEmitPromise(socket, 'connections:subscribe', []) + .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('instances:patch', patchInstances) + 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) @@ -245,7 +271,7 @@ export function ContextData({ children }) { socket.off('triggers:update', updateTriggers) - socket.off('instances:patch', patchInstances) + socket.off('connections:patch', patchInstances) socket.off('modules:patch', patchModules) socketEmitPromise(socket, 'action-definitions:unsubscribe', []).catch((e) => { @@ -257,7 +283,7 @@ export function ContextData({ children }) { socketEmitPromise(socket, 'variable-definitions:unsubscribe', []).catch((e) => { console.error('Failed to unsubscribe to variable definitions list', e) }) - socketEmitPromise(socket, 'instances:unsubscribe', []).catch((e) => { + socketEmitPromise(socket, 'connections:unsubscribe', []).catch((e) => { console.error('Failed to unsubscribe from instances list:', e) }) socketEmitPromise(socket, 'modules:unsubscribe', []).catch((e) => { @@ -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 ( - - - - - + + + + + - + - - - + + + @@ -319,7 +347,7 @@ export function ContextData({ children }) { - + diff --git a/webui/src/Controls/ActionSetEditor.jsx b/webui/src/Controls/ActionSetEditor.tsx similarity index 62% rename from webui/src/Controls/ActionSetEditor.jsx rename to webui/src/Controls/ActionSetEditor.tsx index 8d9ec41eaf..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, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, memo, useCallback, useContext, useMemo, useRef } from 'react' import { NumberInputField } from '../Components' import { ActionsContext, - InstancesContext, + ConnectionsContext, MyErrorBoundary, socketEmitPromise, - sandbox, SocketContext, PreventDefaultHandler, RecentActionsContext, @@ -23,14 +22,29 @@ 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 function ControlActionSetEditor({ +export const ControlActionSetEditor = memo(function ControlActionSetEditor({ controlId, location, stepId, @@ -39,58 +53,65 @@ export 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 bank action option', e) + console.error('Failed to set control action option', e) } ) }, [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 bank action delay', e) + console.error('Failed to set control action delay', e) }) }, [socket, controlId, stepId, setId] ) const emitDelete = useCallback( - (actionId) => { + (actionId: string) => { 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] ) const emitDuplicate = useCallback( - (actionId) => { + (actionId: string) => { 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] ) const emitLearn = useCallback( - (actionId) => { + (actionId: string) => { 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] ) 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, @@ -100,14 +121,14 @@ 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] ) 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,11 +137,13 @@ 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) - }) + (actionType: string) => { + const [connectionId, actionId] = actionType.split(':', 2) + socketEmitPromise(socket, 'controls:action:add', [controlId, stepId, setId, connectionId, actionId]).catch( + (e) => { + console.error('Failed to add control action', e) + } + ) }, [socket, controlId, stepId, setId] ) @@ -150,7 +173,6 @@ export function ControlActionSetEditor({ ) +}) + +interface AddActionsPanelProps { + addPlaceholder: string + addAction: (actionType: string) => void } -function AddActionsPanel({ addPlaceholder, addAction }) { - const addActionsRef = useRef(null) +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 ( @@ -191,11 +216,36 @@ function AddActionsPanel({ addPlaceholder, addAction }) { ) +}) + +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, @@ -211,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 { @@ -238,7 +288,6 @@ export function ActionsList({ index={i} stepId={stepId} setId={setId} - controlId={controlId} dragId={dragId} setValue={doSetValue} doDelete={doDelete2} @@ -265,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 (
No connections
{snapshotModule ? ( - setInstanceRemap(key, e.target.value)}> + setInstanceRemap(key, e.currentTarget.value)} + > {currentInstances.map(([id, inst]) => ( @@ -205,7 +237,7 @@ export function ImportRemap({ snapshot, instanceRemap, setInstanceRemap }) { ) } -function ButtonImportPreview({ instanceId, ...props }) { +function ButtonImportPreview({ ...props }: ButtonInfiniteGridButtonProps) { const socket = useContext(SocketContext) const [previewImage, setPreviewImage] = useState(null) @@ -223,7 +255,7 @@ function ButtonImportPreview({ instanceId, ...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/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 72% rename from webui/src/ImportExport/index.jsx rename to webui/src/ImportExport/index.tsx index 0ef7477b37..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 { InstancesContext, SocketContext, socketEmitPromise } from '../util' +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 instancesContext = useContext(InstancesContext) + 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,18 +45,20 @@ 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 = '' - for (const [otherId, otherObj] of Object.entries(instancesContext)) { + for (const [otherId, otherObj] of Object.entries(connectionsContext)) { if (otherObj.instance_type === obj.instance_type) { candidateIds.push(otherId) if (otherObj.label === obj.label) { @@ -84,7 +87,7 @@ export function ImportExport() { } fr.readAsArrayBuffer(newFile) }, - [socket, instancesContext] + [socket, connectionsContext] ) if (importInfo) { diff --git a/webui/src/Instances/InstanceVariablesModal.jsx b/webui/src/Instances/InstanceVariablesModal.jsx deleted file mode 100644 index eb45729f1f..0000000000 --- a/webui/src/Instances/InstanceVariablesModal.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 InstanceVariablesModal = forwardRef(function HelpModal(_props, ref) { - const [instanceLabel, setInstanceLabel] = useState(null) - const [show, setShow] = useState(false) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => setInstanceLabel(null), []) - - useImperativeHandle( - ref, - () => ({ - show(label) { - setInstanceLabel(label) - setShow(true) - }, - }), - [] - ) - - return ( - - -
Variables for {instanceLabel}
-
- - - - - - - - - - Close - - -
- ) -}) diff --git a/webui/src/Instances/index.jsx b/webui/src/Instances/index.jsx deleted file mode 100644 index 9cf80cc336..0000000000 --- a/webui/src/Instances/index.jsx +++ /dev/null @@ -1,130 +0,0 @@ -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 { NotifierContext, MyErrorBoundary, socketEmitPromise, SocketContext } from '../util' -import { InstancesList } from './InstanceList' -import { AddInstancesPanel } from './AddInstance' -import { InstanceEditPanel } from './InstanceEditPanel' -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() { - const socket = useContext(SocketContext) - const notifier = useContext(NotifierContext) - - const helpModalRef = useRef() - - const [tabResetToken, setTabResetToken] = useState(nanoid()) - const [activeTab, setActiveTab] = useState('add') - const [selectedInstanceId, setSelectedInstanceId] = useState(null) - const doChangeTab = useCallback((newTab) => { - setActiveTab((oldTab) => { - if (oldTab !== newTab) { - setSelectedInstanceId(null) - setTabResetToken(nanoid()) - } - return newTab - }) - }, []) - - const showHelp = useCallback( - (id) => { - socketEmitPromise(socket, 'instances:get-help', [id]).then(([err, result]) => { - if (err) { - notifier.current.show('Instance help', `Failed to get help text: ${err}`) - return - } - if (result) { - helpModalRef.current?.show(id, result) - } - }) - }, - [socket, notifier] - ) - - const doConfigureInstance = useCallback((id) => { - setSelectedInstanceId(id) - setTabResetToken(nanoid()) - setActiveTab(id ? 'edit' : 'add') - }, []) - - const [instanceStatus, setInstanceStatus] = useState(null) - useEffect(() => { - socketEmitPromise(socket, 'instance_status:get', []) - .then((statuses) => { - setInstanceStatus(statuses) - }) - .catch((e) => { - console.error(`Failed to load instance statuses`, e) - }) - - const patchStatuses = (patch) => { - setInstanceStatus((oldStatuses) => { - if (!oldStatuses) return oldStatuses - return jsonPatch.applyPatch(cloneDeep(oldStatuses) || {}, patch).newDocument - }) - } - socket.on('instance_status:patch', patchStatuses) - - return () => { - socket.off('instance_status:patch', patchStatuses) - } - }, [socket]) - - return ( - - - - - - - - -
- - - - - Add connection - - - - - - - - - - - - - {selectedInstanceId && ( - - )} - - - - -
-
-
- ) -}) 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 72% rename from webui/src/LogPanel.jsx rename to webui/src/LogPanel.tsx index edbcca84e3..df4d0f0798 100644 --- a/webui/src/LogPanel.jsx +++ b/webui/src/LogPanel.tsx @@ -1,18 +1,36 @@ 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' + +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', + message: 'You can view older logs in the configuration folder', +} 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(() => { @@ -25,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], @@ -37,9 +55,12 @@ export const LogPanel = memo(function LogPanel() { const doToggleDebug = useCallback(() => doToggleConfig('debug'), [doToggleConfig]) const exportSupportModal = useCallback(() => { - exportRef.current.show( + 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') @@ -104,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() })) @@ -131,7 +155,7 @@ function LogPanelContents({ config }) { } socketEmitPromise(socket, 'logs:subscribe', []) - .then((lines) => { + .then((lines: ClientLogLine[]) => { const items = lines.map((item) => ({ ...item, id: nanoid(), @@ -155,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) @@ -168,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(() => { @@ -219,15 +243,17 @@ 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 = messages[index] + const h = index === 0 ? LogsOnDiskInfoLine : messages[index - 1] useEffect(() => { if (rowRef.current) { @@ -243,14 +269,14 @@ function LogPanelContents({ config }) { ) } - const outerRef = useRef(null) + const outerRef = useRef(null) return ( {({ height, width }) => ( { - const time_format = dayjs(h.time).format('YY.MM.DD HH:mm:ss') +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 (
{time_format} {h.source}: {h.message} @@ -273,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.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 2bec7a3af9..0000000000 --- a/webui/src/Surfaces/EditModal.jsx +++ /dev/null @@ -1,291 +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 } from '../util' -import { nanoid } from 'nanoid' - -export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { - const socket = useContext(SocketContext) - - const [deviceInfo, setDeviceInfo] = useState(null) - const [show, setShow] = useState(false) - - const [deviceConfig, setDeviceConfig] = useState(null) - const [deviceConfigError, setDeviceConfigError] = useState(null) - const [reloadToken, setReloadToken] = useState(nanoid()) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setDeviceInfo(null) - setDeviceConfig(null) - setDeviceConfigError(null) - }, []) - - const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) - - useEffect(() => { - setDeviceConfigError(null) - setDeviceConfig(null) - - if (deviceInfo?.id) { - socketEmitPromise(socket, 'surfaces:config-get', [deviceInfo.id]) - .then((config) => { - console.log(config) - setDeviceConfig(config) - }) - .catch((err) => { - console.error('Failed to load device config') - setDeviceConfigError(`Failed to load device config`) - }) - } - }, [socket, deviceInfo?.id, reloadToken]) - - useImperativeHandle( - ref, - () => ({ - show(device) { - setDeviceInfo(device) - setShow(true) - }, - ensureIdIsValid(deviceIds) { - setDeviceInfo((oldDevice) => { - if (oldDevice && deviceIds.indexOf(oldDevice.id) === -1) { - setShow(false) - } - return oldDevice - }) - }, - }), - [] - ) - - const updateConfig = useCallback( - (key, value) => { - console.log('update', key, value) - if (deviceInfo?.id) { - setDeviceConfig((oldConfig) => { - const newConfig = { - ...oldConfig, - [key]: value, - } - - socketEmitPromise(socket, 'surfaces:config-set', [deviceInfo.id, newConfig]) - .then((newConfig) => { - if (typeof newConfig === 'string') { - console.log('Config update failed', newConfig) - } else { - setDeviceConfig(newConfig) - } - }) - .catch((e) => { - console.log('Config update failed', e) - }) - return newConfig - }) - } - }, - [socket, deviceInfo?.id] - ) - - return ( - - -
Settings for {deviceInfo?.type}
-
- - - {deviceConfig && deviceInfo && ( - - - Use Last Page At Startup - updateConfig('use_last_page', !!e.currentTarget.checked)} - /> - - - Startup Page - updateConfig('page', parseInt(e.currentTarget.value))} - /> - {deviceConfig.page} - - {deviceInfo.configFields?.includes('emulator_size') && ( - <> - - Row count - updateConfig('emulator_rows', parseInt(e.currentTarget.value))} - /> - - - Column count - updateConfig('emulator_columns', parseInt(e.currentTarget.value))} - /> - - - )} - - - Horizontal Offset in grid - updateConfig('xOffset', parseInt(e.currentTarget.value))} - /> - - - Vertical Offset in grid - updateConfig('yOffset', parseInt(e.currentTarget.value))} - /> - - - {deviceInfo.configFields?.includes('brightness') && ( - - Brightness - updateConfig('brightness', parseInt(e.currentTarget.value))} - /> - - )} - {deviceInfo.configFields?.includes('illuminate_pressed') && ( - - Illuminate pressed buttons - updateConfig('illuminate_pressed', !!e.currentTarget.checked)} - /> - - )} - - - Button rotation - { - const valueNumber = parseInt(e.currentTarget.value) - updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) - }} - > - - - - - - {deviceInfo.configFields?.includes('legacy_rotation') && ( - <> - - - - - )} - - - {deviceInfo.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') && ( - - Prompt to enter fullscreen - updateConfig('emulator_prompt_fullscreen', !!e.currentTarget.checked)} - /> - - )} - {deviceInfo.configFields?.includes('videohub_page_count') && ( - - Page Count - updateConfig('videohub_page_count', parseInt(e.currentTarget.value))} - /> - - )} - - Never Pin code lock - updateConfig('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.jsx deleted file mode 100644 index 22684b8a08..0000000000 --- a/webui/src/Surfaces/index.jsx +++ /dev/null @@ -1,271 +0,0 @@ -import React, { memo, useCallback, useContext, useEffect, 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' - -export const SurfacesPage = memo(function SurfacesPage() { - const socket = useContext(SocketContext) - const devices = useContext(SurfacesContext) - - const confirmRef = useRef(null) - - const devicesList = useMemo(() => { - const ary = Object.values(devices.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 - }, [devices.available]) - const offlineDevicesList = useMemo(() => { - const ary = Object.values(devices.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 - }, [devices.offline]) - - const editModalRef = useRef() - const confirmModalRef = useRef(null) - - const [scanning, setScanning] = useState(false) - const [scanError, setScanError] = useState(null) - - useEffect(() => { - // If device disappears, hide the edit modal - if (editModalRef.current) { - editModalRef.current.ensureIdIsValid(Object.keys(devices)) - } - }, [devices]) - - const refreshUSB = useCallback(() => { - setScanning(true) - setScanError(null) - - socketEmitPromise(socket, 'surfaces:rescan', [], 30000) - .then((errorMsg) => { - setScanError(errorMsg || null) - setScanning(false) - }) - .catch((err) => { - console.error('Refresh USB failed', err) - - setScanning(false) - }) - }, [socket]) - - const addEmulator = useCallback(() => { - socketEmitPromise(socket, 'surfaces:emulator-add', []).catch((err) => { - console.error('Emulator add failed', err) - }) - }, [socket]) - - const deleteEmulator = useCallback( - (deviceId) => { - confirmRef?.current?.show('Remove Emulator', 'Are you sure?', 'Remove', () => { - socketEmitPromise(socket, 'surfaces:emulator-remove', [deviceId]).catch((err) => { - console.error('Emulator remove failed', err) - }) - }) - }, - [socket] - ) - - const configureDevice = useCallback((device) => { - editModalRef.current.show(device) - }, []) - - const forgetDevice = useCallback( - (deviceId) => { - 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) => { - console.error('fotget failed', err) - }) - } - ) - }, - [socket] - ) - - const updateName = useCallback( - (deviceId, name) => { - socketEmitPromise(socket, 'surfaces:set-name', [deviceId, name]).catch((err) => { - console.error('Update name failed', err) - }) - }, - [socket] - ) - - return ( -
- - -

Surfaces

-

- These are the surfaces currently connected to companion. If your streamdeck is missing from this list, you might - need to close the Elgato Streamdeck application and click the Rescan button below. -

- - - Did you know, you can connect a Streamdeck from another computer or Raspberry Pi with{' '} - - Companion Satellite - - ? - - - - {scanError} - - - - - - {scanning ? ' Checking for new devices...' : ' Rescan USB'} - - - Add Emulator - - - -

 

- - - - -
Connected
- - - - - - - - - - - - - - {devicesList.map((dev) => ( - - ))} - - {devicesList.length === 0 && ( - - - - )} - -
NOIDNameTypeLocation 
No control surfaces have been detected
- -
Disconnected
- - - - - - - - - - - - {offlineDevicesList.map((dev) => ( - - ))} - - {offlineDevicesList.length === 0 && ( - - - - )} - -
IDNameType 
No items
-
- ) -}) - -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]) - - return ( -
#{device.index}{device.id} - - {device.type}{device.location} - - - Settings - - - {device.integrationType === 'emulator' && ( - <> - - - - - - - - )} - -
{device.id} - - {device.type} - - Forget - -
+ + + + + + + + + + + + {surfacesList.map((group) => { + if (group.isAutoGroup && (group.surfaces || []).length === 1) { + return ( + + ) + } else { + return ( + + ) + } + })} + + {surfacesList.length === 0 && ( + + + + )} + +
NOIDNameTypeLocation 
No control surfaces have been detected
+
+ ) +}) + +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, + deleteGroup, + updateName, + 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]) + + return ( + <> + + #{group.index} + {group.id} + + + + Group + - + + + + Settings + + + + Delete + + + + + {(group.surfaces || []).map((surface) => ( + + ))} + + ) +} + +interface SurfaceRowProps { + surface: ClientSurfaceItem + index: number | undefined + updateName: (surfaceId: string, name: string) => 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]) + const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) + + return ( + + {index !== undefined ? `#${index}` : ''} + {surface.id} + + + + {surface.type} + {surface.isConnected ? surface.location || 'Local' : 'Offline'} + + {surface.isConnected ? ( + + + Settings + + + {surface.integrationType === 'emulator' && ( + <> + + + + + + + + )} + + ) : ( + + Forget + + )} + + + ) +} diff --git a/webui/src/TabletView/ButtonsFromPage.jsx b/webui/src/TabletView/ButtonsFromPage.tsx similarity index 67% rename from webui/src/TabletView/ButtonsFromPage.jsx rename to webui/src/TabletView/ButtonsFromPage.tsx index 7a69b8e48f..a1e11fb837 100644 --- a/webui/src/TabletView/ButtonsFromPage.jsx +++ b/webui/src/TabletView/ButtonsFromPage.tsx @@ -1,14 +1,34 @@ -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 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 +71,7 @@ export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSi buttonSize={buttonSize} displayColumn={displayColumn} displayRow={displayRow} - bankClick={bankClick} + buttonClick={buttonClick} /> ) } @@ -65,7 +85,25 @@ export function ButtonsFromPage({ pageNumber, displayColumns, gridSize, buttonSi ) } -function ButtonWrapper({ pageNumber, column, row, buttonSize, displayColumn, displayRow, bankClick }) { + +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 75% rename from webui/src/TabletView/index.jsx rename to webui/src/TabletView/index.tsx index 25bb40aab0..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) @@ -149,7 +171,7 @@ export function TabletView() {
- +
{validPages.map((number, i) => ( @@ -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 71% rename from webui/src/Triggers/EditPanel.jsx rename to webui/src/Triggers/EditPanel.tsx index 5e9c2e4550..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()) @@ -41,22 +52,22 @@ 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) => { + 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 {} @@ -74,7 +85,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]) @@ -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 73% rename from webui/src/Triggers/EventEditor.jsx rename to webui/src/Triggers/EventEditor.tsx index 634d9e1f87..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, { 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,16 +66,16 @@ 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 bank event', e) + console.error('Failed to add trigger event', e) }) }, [socket, controlId] ) 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({ 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]) - - 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) + const [eventOptions, optionVisibility] = useOptionsAndIsVisible(eventSpec, event) - 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' } -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 @@ function AddEventDropdown({ onSelect }) { }, [EventDefinitions]) const innerChange = useCallback( - (e) => { - if (e.value) { - onSelect(e.value) + (e: DropdownChoice | null) => { + if (e?.id) { + onSelect(e.id) } }, [onSelect] @@ -400,4 +406,4 @@ function AddEventDropdown({ onSelect }) { noOptionsMessage={noOptionsMessage} /> ) -} +}) diff --git a/webui/src/Triggers/index.jsx b/webui/src/Triggers/index.tsx similarity index 82% rename from webui/src/Triggers/index.jsx rename to webui/src/Triggers/index.tsx index 673bb59212..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 }) { {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]) => ( - + There currently are no triggers or scheduled tasks. @@ -214,10 +223,26 @@ function TriggersTable({ triggersList, editItem, selectedControlId }) { ) } -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, @@ -286,20 +311,15 @@ 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 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 ( <> - + Buttons 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 ( <> - + Ember+ 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 ( <> - + Experiments - + Do not touch these settings unless you know what you are doing! 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 ( <> - + Grid @@ -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.tsx b/webui/src/UserConfig/HttpConfig.tsx new file mode 100644 index 0000000000..734254196c --- /dev/null +++ b/webui/src/UserConfig/HttpConfig.tsx @@ -0,0 +1,64 @@ +import React from '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' + +interface HttpConfigProps { + config: UserConfigModel + setValue: (key: keyof UserConfigModel, value: any) => void + resetValue: (key: keyof UserConfigModel) => void +} + +export function HttpConfig({ config, setValue, resetValue }: HttpConfigProps) { + 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"> + + + + + + ) +} diff --git a/webui/src/UserConfig/HttpProtocol.jsx b/webui/src/UserConfig/HttpProtocol.jsx deleted file mode 100644 index 0ae148d529..0000000000 --- a/webui/src/UserConfig/HttpProtocol.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react' - -export function HttpProtocol() { - return ( - <> -

Remote triggering can be done by sending HTTP Requests to the same IP and port Companion is running on.

-

- Commands: -

-
    -
  • - /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 -
  • -
- -

- Examples -

- -

- Press page 1 button 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/webui/src/UserConfig/HttpProtocol.tsx b/webui/src/UserConfig/HttpProtocol.tsx new file mode 100644 index 0000000000..c71f1bbe9c --- /dev/null +++ b/webui/src/UserConfig/HttpProtocol.tsx @@ -0,0 +1,188 @@ +import React from 'react' + +export function HttpProtocol() { + return ( + <> +

Remote triggering can be done by sending HTTP Requests to the same IP and port Companion is running on.

+

+ 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/<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> +
    + 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) +
  • + +
  • + POST /api/custom-variable/<name>/value?value=<value> +
    + Change custom variable value +
  • +
  • + POST /api/custom-variable/<name>/value <value> +
    + Change custom variable value +
  • +
  • + POST /surfaces/rescan +
    + Make Companion rescan for newly attached USB surfaces +
  • +
+ +

+ Examples +

+ +

+ Press page 1 button 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 +

+ +

+ 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/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 ( <> - + 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.
+ - - @@ -47,6 +54,28 @@ export function OscConfig({ config, setValue, resetValue }) { + + + + + ) } diff --git a/webui/src/UserConfig/OscProtocol.jsx b/webui/src/UserConfig/OscProtocol.jsx deleted file mode 100644 index e2854f2bc4..0000000000 --- a/webui/src/UserConfig/OscProtocol.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useContext } from 'react' -import { UserConfigContext } from '../util' - -export function OscProtocol() { - const config = useContext(UserConfigContext) - - return ( - <> -

- 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'} - - . -

-

- Commands: -

-
    -
  • - /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 -
  • -
  • - /custom-variable/<name>/value <value> -
    - Change custom variable value -
  • -
  • - /rescan 1 -
    - Make Companion rescan for newly attached USB surfaces -
  • -
- -

- Examples -

- -

- Press button 5 on page 1 down and hold -
- /press/bank/1/5 1 -

- -

- Change button background color of button 5 on page 1 to red -
- /style/bgcolor/1/5 255 0 0 -

- -

- Change the text of button 5 on page 1 to ONLINE -
- /style/text/1/5 ONLINE -

- -

- Change custom variable "cue" to value "intro" -
- /custom-variable/cue/value intro -

- - ) -} diff --git a/webui/src/UserConfig/OscProtocol.tsx b/webui/src/UserConfig/OscProtocol.tsx new file mode 100644 index 0000000000..b0d6c38ca6 --- /dev/null +++ b/webui/src/UserConfig/OscProtocol.tsx @@ -0,0 +1,174 @@ +import React, { useContext } from 'react' +import { UserConfigContext } from '../util' + +export function OscProtocol() { + const config = useContext(UserConfigContext) + + return ( + <> +

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

+

+ Commands: +

+
    +
  • + /location/<page>/<row>/<column>/press +
    + Press and release a button (run both down and up actions) +
  • +
  • + /location/<page>/<row>/<column>/down +
    + Press the button (run down actions and hold) +
  • +
  • + /location/<page>/<row>/<column>/up +
    + Release the button (run up actions) +
  • +
  • + /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 +
  • +
  • + /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 +
  • +
  • + /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 +
  • +
  • + /surfaces/rescan +
    + Make Companion rescan for newly attached USB surfaces +
  • +
+ +

+ Examples +

+ +

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

+ +

+ 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 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/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/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 55% rename from webui/src/UserConfig/TcpConfig.jsx rename to webui/src/UserConfig/TcpConfig.tsx index f03bfc4f0b..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 ( <> - @@ -47,6 +54,28 @@ export function TcpConfig({ config, setValue, resetValue }) { + + + + + ) } diff --git a/webui/src/UserConfig/TcpUdpProtocol.jsx b/webui/src/UserConfig/TcpUdpProtocol.jsx deleted file mode 100644 index d62871b7e6..0000000000 --- a/webui/src/UserConfig/TcpUdpProtocol.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useContext } from 'react' -import { UserConfigContext } from '../util' - -export function TcpUdpProtocol() { - const config = useContext(UserConfigContext) - - 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. -

-

- Commands: -

-
    -
  • - 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 -
  • -
- -

- Examples -

- -

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

- -

- Press page 1 button 2 -
- BANK-PRESS 1 2 -

- -

- Change custom variable "cue" to value "intro" -
- CUSTOM-VARIABLE cue SET-VALUE intro -

- - ) -} diff --git a/webui/src/UserConfig/TcpUdpProtocol.tsx b/webui/src/UserConfig/TcpUdpProtocol.tsx new file mode 100644 index 0000000000..43c168258f --- /dev/null +++ b/webui/src/UserConfig/TcpUdpProtocol.tsx @@ -0,0 +1,193 @@ +import React, { useContext } from 'react' +import { UserConfigContext } from '../util' + +export function TcpUdpProtocol() { + const config = useContext(UserConfigContext) + + 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 ( + <> +

+ Remote triggering can be done by sending TCP (port {tcpPort}) or UDP (port {udpPort}) + commands. +

+

+ Commands: +

+
    +
  • + SURFACE <surface id> PAGE-SET <page number> +
    + Set a surface to a specific page +
  • +
  • + SURFACE <surface id> PAGE-UP +
    + Page up on a specific surface +
  • +
  • + SURFACE <surface id> PAGE-DOWN +
    + Page down on a specific surface +
  • + +
  • + LOCATION <page>/<row>/<column>{' '} + BANK-PRESS +
    + Press and release a button (run both down and up actions) +
  • +
  • + LOCATION <page>/<row>/<column> BANK-DOWN +
    + Press the button (run down actions) +
  • +
  • + LOCATION <page>/<row>/<column> BANK-UP +
    + Release the button (run up actions) +
  • +
  • + 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 +
  • +
  • + LOCATION <page>/<row>/<column>{' '} + STYLE COLOR <color HEX> +
    + Change text color on a button (#000000) +
  • +
  • + 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 +
  • +
  • + 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 <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.tsx similarity index 55% rename from webui/src/UserConfig/UdpConfig.jsx rename to webui/src/UserConfig/UdpConfig.tsx index f049e9d1ba..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 ( <> - @@ -47,6 +54,28 @@ export function UdpConfig({ config, setValue, resetValue }) { + + + + + ) } 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 97% rename from webui/src/UserConfig/index.jsx rename to webui/src/UserConfig/index.tsx index 5ba3c09379..f12ec62645 100644 --- a/webui/src/UserConfig/index.jsx +++ b/webui/src/UserConfig/index.tsx @@ -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 ( @@ -64,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 55% rename from webui/src/UserConfig/OscConfig.jsx rename to webui/src/UserConfig/OscConfig.tsx index 69e2e85f2e..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
+ 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"> + + +
+ 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
+ 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"> + + +
+ UDP
+ 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"> + + +
+ Videohub Panel
@@ -75,6 +78,7 @@ function UserConfigTable() { + 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/_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/_common.scss b/webui/src/scss/_common.scss index 6eb683d0cc..38f5588a67 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,11 @@ code { .c-switch-input { display: none; } + +.displayNone { + display: none; +} + +.noBorder td { + border: none; +} 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/_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/scss/_layout.scss b/webui/src/scss/_layout.scss index bab289f040..50fef293d7 100644 --- a/webui/src/scss/_layout.scss +++ b/webui/src/scss/_layout.scss @@ -75,7 +75,7 @@ body { margin-right: 5px; } -.choose_instance { +.choose_connection { width: 45%; float: left; } diff --git a/webui/src/scss/_tablet.scss b/webui/src/scss/_tablet.scss index c6a620c05c..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; } @@ -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; 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 85f6fb210e..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 InstancesContext = 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" 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"