Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@
## 1.0.0-beta.10 [UNRELEASED]
### Added
- Human/AI readable descriptors for types
- Free run for LTC inputs
- Free wheel for LTC inputs
- Reference items now show a warning if they're missing a target id
- Keyboard triggers
### Fixed
- An issue where invalid types could crash the rundown widget
- An issue where 0 (or falsy values) could not be used as ids for options in preferences of the select type
- Preferences can no longer be opened in floating widget windows
- Confirm dialogs now abort on escape
- The shortcut for opening settings now works as intented
- Settings no longer collide with other modals
- An issue where children weren't removed when their parent group was removed
### Changed
- Keyboard shortcuts now use hotkeys-js for better stability
- The item.apply event has been changed to item.change

## 1.0.0-beta.9
### Added
Expand Down
39 changes: 39 additions & 0 deletions api/browser/shortcuts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Axel Boberg
//
// SPDX-License-Identifier: MIT

const DIController = require('../../shared/DIController')
const Shortcuts = require('../shared/shortcuts')

const InvalidArgumentError = require('../error/InvalidArgumentError')

const COMMAND_IDENTIFIER = 'command:'

class ClientShortcuts extends Shortcuts {
dispatchShortcut (action) {
if (typeof action !== 'string') {
throw new InvalidArgumentError('Shortcut action must be a string')
}

/*
If the action is a command, that is, starting with 'command:',
execute it rather than emitting the shortcut event
*/
if (action.startsWith(COMMAND_IDENTIFIER)) {
const command = action.substring(COMMAND_IDENTIFIER.length)
this.props.Commands.executeCommand(command)
} else {
this.props.Events.emitLocally('shortcut', action)
}
}
}

DIController.main.register('Shortcuts', ClientShortcuts, [
/*
This list must include requirements
from the base Shortcuts class
*/
'State',
'Events',
'Commands'
])
8 changes: 5 additions & 3 deletions api/client.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// SPDX-FileCopyrightText: 2022 Sveriges Television AB
// SPDX-FileCopyrightText: 2026 Axel Boberg
//
// SPDX-License-Identifier: MIT

const environment = require('./shared/environment')

;(function () {
if (module.parent) {
if (environment.isNode()) {
require('./node/client')
return
}
if (typeof window !== 'undefined') {
if (environment.isBrowser()) {
require('./browser/client')
}
})()
15 changes: 15 additions & 0 deletions api/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ class Events {
}

const index = handlers.findIndex(({ fn }) => fn === handler)
if (index < 0) {
return
}

handlers.splice(index, 1)

if (handlers.length === 0) {
Expand Down Expand Up @@ -219,6 +223,17 @@ class Events {

const handlers = this.localHandlers.get(event)
const index = handlers.findIndex(({ handler: _handler }) => _handler === handler)

/*
Return if there's
no matching handler,
otherwise the last inserted
handler will be removed
*/
if (index < 0) {
return
}

handlers.splice(index, 1)

if (handlers.length === 0) {
Expand Down
26 changes: 15 additions & 11 deletions api/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ class Items {
Intercept the item.change event
to always include the full item
*/
this.#props.Events.intercept('item.change', async itemId => {
return await this.getItem(itemId)
this.#props.Events.intercept('item.change', async (itemId, set) => {
return [itemId, await this.getItem(itemId), set]
})
}

Expand Down Expand Up @@ -112,18 +112,19 @@ class Items {
*/
Object.assign(item.data, _data)

this.applyItem(item.id, item)
this.applyItem(item.id, item, true)
return item.id
}

/**
* Apply changes to an
* item in the this.#props.State
*
* @param { String } id The id of an item to update
* @param { string } id The id of an item to update
* @param { Item } set An item object to apply
* @param { boolean } emitEvent Whether or not to emit the item.change event
*/
async applyItem (id, set = {}) {
async applyItem (id, set = {}, emitEvent = false) {
if (typeof id !== 'string') {
throw new MissingArgumentError('Invalid value for item id, must be a string')
}
Expand All @@ -138,7 +139,9 @@ class Items {
}
})

this.#props.Events.emit('item.apply', id, set)
if (emitEvent) {
this.#props.Events.emit('item.change', id, set)
}
}

/**
Expand All @@ -150,15 +153,16 @@ class Items {
* item exists before applying
* the data
*
* @param { String } id The id of an item to update
* @param { string } id The id of an item to update
* @param { Item } set An item object to apply
* @param { boolean } emitEvent Whether or not to emit the item.change event
*/
async applyExistingItem (id, set = {}) {
async applyExistingItem (id, set = {}, emitEvent = false) {
const itemExists = await this.itemExists(id)
if (!itemExists) {
return
}
await this.applyItem(id, set)
await this.applyItem(id, set, emitEvent)
}

async itemExists (id) {
Expand Down Expand Up @@ -352,7 +356,7 @@ class Items {
...issueSpec
}
}
})
}, false)
}

/**
Expand All @@ -375,7 +379,7 @@ class Items {
issues: {
[issueId]: { $delete: true }
}
})
}, false)
}

/**
Expand Down
15 changes: 15 additions & 0 deletions api/node/shortcuts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2026 Axel Boberg
//
// SPDX-License-Identifier: MIT

const DIController = require('../../shared/DIController')
const Shortcuts = require('../shared/shortcuts')

DIController.main.register('Shortcuts', Shortcuts, [
/*
This list must include requirements
from the base Shortcuts class
*/
'State',
'Commands'
])
9 changes: 9 additions & 0 deletions api/shared/environment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
function isNode () {
return !!module.parent
}
exports.isNode = isNode

function isBrowser () {
return typeof window !== 'undefined'
}
exports.isBrowser = isBrowser
134 changes: 134 additions & 0 deletions api/shared/shortcuts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2022 Sveriges Television AB
//
// SPDX-License-Identifier: MIT

/**
* @typedef {{
* id: String,
* action: String,
* description: String,
* trigger: String[]
* }} ShortcutSpec
*
* @typedef {{
* trigger: String[]
* }} ShortcutOverrideSpec
*/

const InvalidArgumentError = require('../error/InvalidArgumentError')
const DIBase = require('../../shared/DIBase')

class Shortcuts extends DIBase {
/**
* Make a shortcut available
* to the application
* @param { ShortcutSpec } spec
*/
registerShortcut (spec = {}) {
return this.props.Commands.executeCommand('shortcuts.registerShortcut', spec)
}

/**
* Get a shortcut's
* specification
* @param { String } id
* @returns { Promise.<ShortcutSpec> }
*/
getShortcut (id) {
if (!id || typeof id !== 'string') {
return
}
return this.props.State.getLocalState()?._shortcuts?.[id]
}

/**
* Remove a registered shortcut
* @param { string } id
*/
removeShortcut (id) {
if (!id || typeof id !== 'string') {
return
}
this.props.Commands.executeCommand('shortcuts.removeShortcut', id)
}

/**
* Get all shortcuts'
* specifications
* @returns { Promise.<ShortcutSpec[]> }
*/
async getShortcuts () {
const index = this.props.State.getLocalState()?._shortcuts
const overrides = this.props.State.getLocalState()?._userDefaults?.shortcuts || {}

return Object.values(index || {})
.map(shortcut => {
return {
...shortcut,
...(overrides[shortcut.id] || {})
}
})
}

/**
* Register a new shortcut override
*
* Note that the override will be registered
* to the user defaults this.props.State for the current
* main process and not necessarily the local
* user
*
* @param { String } id An identifier of the shortcut to override
* @param { ShortcutOverrideSpec } spec A specification to use as an override
* @returns { Promise.<void> }
*/
async registerShortcutOverride (id, spec) {
if (typeof spec !== 'object') {
throw new InvalidArgumentError('The provided \'spec\' must be a shortcut override specification')
}

if (typeof id !== 'string') {
throw new InvalidArgumentError('The provided \'id\' must be a string')
}

const currentOverride = await this.props.State.get(`_userDefaults.shortcuts.${id}`)
const set = { [id]: spec }

if (currentOverride) {
set[id] = { $replace: spec }
}

this.props.State.apply({
_userDefaults: {
shortcuts: set
}
})
}

/**
* Clear any override for a
* specific shortcut by its id
* @param { String } id
*/
async clearShortcutOverride (id) {
this.props.State.apply({
_userDefaults: {
shortcuts: {
[id]: { $delete: true }
}
}
})
}

/**
* Get a shortcut override specification
* for a shortcut by its id
* @param { String } id
* @returns { Promise.<ShortcutOverrideSpec | undefined> }
*/
async getShortcutOverride (id) {
return this.props.State.get(`_userDefaults.shortcuts.${id}`)
}
}

module.exports = Shortcuts
Loading