From f0ef976debe7d2eb6bd26dcfe726496c84f24442 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 12 Nov 2025 23:16:13 +0100 Subject: [PATCH 1/5] Start moving context menu logic to an api Signed-off-by: Axel Boberg --- api/browser/ui/contextMenu.js | 21 +++ api/browser/ui/index.js | 17 +++ api/index.js | 5 +- api/node/ui.js | 13 ++ api/ui.js | 13 ++ app/App.jsx | 34 +++-- app/components/ContextMenuBoundary/index.jsx | 133 ++++++++++++++++++ .../app/components/RundownGroupItem/index.jsx | 14 +- .../app/components/RundownList/index.jsx | 13 +- .../app/components/RundownListItem/index.jsx | 110 ++++++++------- plugins/rundown/app/utils/contextMenu.js | 63 +++++++++ plugins/rundown/app/views/Rundown.jsx | 26 +++- 12 files changed, 384 insertions(+), 78 deletions(-) create mode 100644 api/browser/ui/contextMenu.js create mode 100644 api/browser/ui/index.js create mode 100644 api/node/ui.js create mode 100644 api/ui.js create mode 100644 app/components/ContextMenuBoundary/index.jsx create mode 100644 plugins/rundown/app/utils/contextMenu.js diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js new file mode 100644 index 00000000..efc9970b --- /dev/null +++ b/api/browser/ui/contextMenu.js @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025 Axel Boberg +// +// SPDX-License-Identifier: MIT + +const DIController = require('../../../shared/DIController') + +class UIContextMenu { + #props + + constructor (props) { + this.#props = props + } + + open (opts, spec) { + this.#props.Events.emitLocally('ui.contextMenu', opts, spec) + } +} + +DIController.main.register('UIContextMenu', UIContextMenu, [ + 'Events' +]) diff --git a/api/browser/ui/index.js b/api/browser/ui/index.js new file mode 100644 index 00000000..c26f83f5 --- /dev/null +++ b/api/browser/ui/index.js @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 Axel Boberg +// +// SPDX-License-Identifier: MIT + +const DIController = require('../../../shared/DIController') + +require('./contextMenu') + +class UI { + constructor (props) { + this.contextMenu = props.UIContextMenu + } +} + +DIController.main.register('UI', UI, [ + 'UIContextMenu' +]) diff --git a/api/index.js b/api/index.js index 01e7eed1..6b49c5cb 100644 --- a/api/index.js +++ b/api/index.js @@ -18,6 +18,7 @@ require('./system') require('./state') require('./types') require('./items') +require('./ui') class API { constructor (props) { @@ -35,6 +36,7 @@ class API { this.state = props.State this.types = props.Types this.items = props.Items + this.ui = props.UI } } @@ -52,7 +54,8 @@ DIController.main.register('API', API, [ 'System', 'State', 'Types', - 'Items' + 'Items', + 'UI' ]) const main = DIController.main.instantiate('API') diff --git a/api/node/ui.js b/api/node/ui.js new file mode 100644 index 00000000..b7f70373 --- /dev/null +++ b/api/node/ui.js @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 Axel Boberg +// +// SPDX-License-Identifier: MIT + +const DIController = require('../../shared/DIController') + +/** + * No-op class as this API + * is only available + * in browser processes + */ +class UI {} +DIController.main.register('UI', UI) diff --git a/api/ui.js b/api/ui.js new file mode 100644 index 00000000..2b4b01e2 --- /dev/null +++ b/api/ui.js @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 Sveriges Television AB +// +// SPDX-License-Identifier: MIT + +;(function () { + if (module.parent) { + require('./node/ui') + return + } + if (typeof window !== 'undefined') { + require('./browser/ui') + } +})() diff --git a/app/App.jsx b/app/App.jsx index 4c5dd01d..59e9f5b3 100644 --- a/app/App.jsx +++ b/app/App.jsx @@ -5,6 +5,7 @@ import { WorkspaceWidget } from './views/WorkspaceWidget' import { Router } from './components/Router' import { Transparency } from './components/Transparency' +import { ContextMenuBoundary } from './components/ContextMenuBoundary' import { LocalContext } from './localContext' import { SharedContext } from './sharedContext' @@ -12,6 +13,7 @@ import { SocketContext } from './socketContext' import { useWebsocket } from './hooks/useWebsocket' + import * as shortcuts from './utils/shortcuts' import * as browser from './utils/browser' import * as auth from './auth' @@ -203,21 +205,23 @@ export default function App () { - - - }, - { - path: /^\/workspaces\/.+$/, - render: () => - }, - { - path: '/', - render: () => - } - ]}/> + + + + }, + { + path: /^\/workspaces\/.+$/, + render: () => + }, + { + path: '/', + render: () => + } + ]}/> + diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx new file mode 100644 index 00000000..43b764ee --- /dev/null +++ b/app/components/ContextMenuBoundary/index.jsx @@ -0,0 +1,133 @@ +import React from 'react' +import * as api from '../../api' + +import { ContextMenu } from '../ContextMenu' +import { ContextMenuItem } from '../ContextMenuItem' +import { ContextMenuDivider } from '../ContextMenuDivider' + +const TYPES = { + item: ContextMenuItem, + divider: ContextMenuDivider +} + +const ALLOWED_SPEC_PROPERTIES = [ + 'type', + 'label' +] + +function isNumber (x) { + return typeof x === 'number' && !Number.isNaN(x) +} + +function getScreenCoordinates () { + return { + x: window.screenLeft, + y: window.screenTop + } +} + +function convertToPageCoordinates (ctxX, ctxY, screenX, screenY) { + return { + x: ctxX - screenX, + y: ctxY - screenY + } +} + +function sanitizeItemSpec (spec) { + const out = {} + for (const property of ALLOWED_SPEC_PROPERTIES) { + out[property] = spec[property] + } + return out +} + +function renderItemSpec (spec, key) { + if (!TYPES[spec?.type]) { + return <> + } + + const Component = TYPES[spec.type] + + function handleClick () { + if (typeof spec?.onClick !== 'function') { + return + } + spec.onClick() + } + + return ( + handleClick()}> + { + (spec?.children || []) + .map((child, i) => renderItemSpec(child, i)) + } + + ) +} + +export function ContextMenuBoundary ({ children }) { + const [contextPos, setContextPos] = React.useState() + const [spec, setSpec] = React.useState() + + React.useEffect(() => { + let bridge + + function onRequestContextMenu (opts, spec) { + if (!isNumber(opts?.x) || !isNumber(opts?.y)) { + console.warn('Missing context menu position') + return + } + + if (!Array.isArray(spec)) { + console.warn('Invalid context spec') + return + } + + const screenCoords = getScreenCoordinates() + const pageCoords = convertToPageCoordinates(opts.x, opts.y, screenCoords.x, screenCoords.y) + + setContextPos({ + x: Math.max(pageCoords.x, 0), + y: Math.max(pageCoords.y, 0) + }) + + setSpec(spec) + } + + async function setup () { + bridge = await api.load() + bridge.events.on('ui.contextMenu', onRequestContextMenu) + } + setup() + + return () => { + if (!bridge) { + return + } + bridge.events.off('ui.contextMenu', onRequestContextMenu) + } + }, []) + + function handleClose () { + setContextPos(undefined) + setSpec(undefined) + } + + return ( + <> + { + contextPos && + ( + handleClose()}> + { + Array.isArray(spec) + ? spec.map((item, i) => renderItemSpec(item, `spec_${i}`)) + : renderItemSpec(spec) + } + + ) + } + {children} + + ) +} \ No newline at end of file diff --git a/plugins/rundown/app/components/RundownGroupItem/index.jsx b/plugins/rundown/app/components/RundownGroupItem/index.jsx index e190ad72..55d5c02a 100644 --- a/plugins/rundown/app/components/RundownGroupItem/index.jsx +++ b/plugins/rundown/app/components/RundownGroupItem/index.jsx @@ -172,16 +172,18 @@ export function RundownGroupItem ({ index, item }) { ) } -export function RundownGroupItemContext ({ item }) { +export function getContextMenuItems (item) { function handleEnterGroup () { window.WIDGET_UPDATE({ 'rundown.id': item.id }) } - return ( - <> - handleEnterGroup()} /> - - ) + return [ + { + type: 'item', + label: 'Step inside', + onClick: () => handleEnterGroup() + } + ] } diff --git a/plugins/rundown/app/components/RundownList/index.jsx b/plugins/rundown/app/components/RundownList/index.jsx index 4d751b40..83019698 100644 --- a/plugins/rundown/app/components/RundownList/index.jsx +++ b/plugins/rundown/app/components/RundownList/index.jsx @@ -7,7 +7,7 @@ import { SharedContext } from '../../sharedContext' import { RundownVariableItem } from '../RundownVariableItem' import { RundownDividerItem } from '../RundownDividerItem' -import { RundownGroupItem, RundownGroupItemContext } from '../RundownGroupItem' +import { RundownGroupItem, getContextMenuItems as rundownGroupItemGetContextMenuItems } from '../RundownGroupItem' import { RundownListItem } from '../RundownListItem' import { RundownItem } from '../RundownItem' @@ -26,7 +26,7 @@ const TYPE_COMPONENTS = { 'bridge.types.divider': { item: RundownDividerItem }, 'bridge.types.group': { item: RundownGroupItem, - context: RundownGroupItemContext + getContextMenuItems: item => rundownGroupItemGetContextMenuItems(item) } } @@ -398,7 +398,12 @@ export function RundownList ({ .map((item, i) => { const isSelected = bridge.client.selection.isSelected(item.id) const ItemComponent = TYPE_COMPONENTS[item.type]?.item || RundownItem - const ExtraContextComponent = TYPE_COMPONENTS[item.type]?.context + + let contextMenuItems + if (typeof TYPE_COMPONENTS[item.type]?.getContextMenuItems === 'function') { + contextMenuItems = TYPE_COMPONENTS[item.type].getContextMenuItems(item) + } + return ( handleDrop(e, i)} onFocus={e => handleFocus(item.id)} onMouseDown={e => blurActiveElementBeforeFocus()} - extraContextItems={ExtraContextComponent} + contextMenuItems={contextMenuItems} selected={isSelected} > diff --git a/plugins/rundown/app/components/RundownListItem/index.jsx b/plugins/rundown/app/components/RundownListItem/index.jsx index 922fb453..6bce4a95 100644 --- a/plugins/rundown/app/components/RundownListItem/index.jsx +++ b/plugins/rundown/app/components/RundownListItem/index.jsx @@ -5,12 +5,7 @@ import './style.css' import { SharedContext } from '../../sharedContext' -import { ContextMenu } from '../../../../../app/components/ContextMenu' -import { ContextMenuItem } from '../../../../../app/components/ContextMenuItem' -import { ContextMenuDivider } from '../../../../../app/components/ContextMenuDivider' - -import { ContextAddMenu } from '../ContextAddMenu' - +import * as contextMenu from '../../utils/contextMenu' import * as clipboard from '../../utils/clipboard' import * as selection from '../../utils/selection' @@ -33,6 +28,10 @@ function getClosestAncestorWithSelector (el, selector) { return el } +function isMultipleItemsSelected () { + return bridge.client.selection.getSelection.length > 1 +} + export function RundownListItem ({ children, item, @@ -42,14 +41,12 @@ export function RundownListItem ({ onFocus = () => {}, onClick = () => {}, onMouseDown = () => {}, - extraContextItems: ExtraContextItemsComponent, + contextMenuItems: extraContextMenuItems, selected: isSelected }) { const [state] = React.useContext(SharedContext) const [isDraggedOver, setIsDraggedOver] = React.useState(false) - const [contextPos, setContextPos] = React.useState() - const [indicateIsPlaying, setIndicateIsPlaying] = React.useState(false) const elRef = React.useRef() @@ -73,7 +70,7 @@ export function RundownListItem ({ onDrop(e) } - function handleContextMenu (e) { + async function handleContextMenu (e) { e.preventDefault() /* @@ -88,7 +85,59 @@ export function RundownListItem ({ return } - setContextPos([e.pageX, e.pageY]) + const types = await bridge.state.get('_types') + + const spec = [ + { + type: 'item', + label: 'Copy', + onClick: () => handleCopy() + }, + { + ...(isMultipleItemsSelected ? {} : { + type: 'item', + label: 'Copy id', + onClick: () => handleCopyId() + }) + }, + { + type: 'item', + label: 'Paste', + onClick: () => handlePaste() + }, + { type: 'divider' }, + { + type: 'item', + label: 'Add after', + children: contextMenu.generateAddContextMenuItems(types, typeId => handleAdd(typeId)) + }, + { + type: 'item', + label: 'Create reference', + onClick: () => handleCreateReference() + }, + { + type: 'item', + label: item?.data?.disabled ? 'Enable' : 'Disable', + onClick: () => selection.disableSelection(!item?.data?.disabled) + }, + { type: 'divider' }, + { + type: 'item', + label: 'Remove', + onClick: () => handleDelete() + }, + ...( + !isMultipleItemsSelected() && extraContextMenuItems + ? [ + { type: 'divider' }, + ...extraContextMenuItems + ] + : [] + ) + ] + + bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY }, spec) } async function handleDelete () { @@ -107,8 +156,9 @@ export function RundownListItem ({ clipboard.copyText(string) } - function handleAdd (newItemId) { - bridge.commands.executeCommand('rundown.moveItem', rundownId, index + 1, newItemId) + async function handleAdd (typeId) { + const itemId = await bridge.items.createItem(typeId) + bridge.commands.executeCommand('rundown.moveItem', rundownId, index + 1, itemId) } async function handleCreateReference () { @@ -145,10 +195,6 @@ export function RundownListItem ({ bridge.commands.executeCommand('rundown.pasteItems', items, rundownId, index + 1) } - const multipleItemsSelected = React.useMemo(() => { - return (state?._connections?.[bridge.client.getIdentity()]?.selection || []).length > 1 - }, [state]) - const isLastPlayed = React.useMemo(() => { return (state?.plugins?.['bridge-plugin-rundown']?.lastPlayedItems || {})[item.id] }, [state, item]) @@ -178,36 +224,6 @@ export function RundownListItem ({ indicateIsPlaying &&
} - { - contextPos && - ( - setContextPos(undefined)}> - handleCopy()} /> - { - !multipleItemsSelected && - handleCopyId()} /> - } - handlePaste()} /> - - - handleAdd(newItemId)} /> - - handleCreateReference()} /> - selection.disableSelection(!item?.data?.disabled)} /> - - handleDelete()} /> - { - ExtraContextItemsComponent && - !multipleItemsSelected && ( - <> - - - - ) - } - - ) - } {children} { isLastPlayed && diff --git a/plugins/rundown/app/utils/contextMenu.js b/plugins/rundown/app/utils/contextMenu.js new file mode 100644 index 00000000..f5422431 --- /dev/null +++ b/plugins/rundown/app/utils/contextMenu.js @@ -0,0 +1,63 @@ +const NO_CATEGORY_ID = '__none__' + +function orderTypesByCategory (types) { + const out = {} + + for (const type of Object.values(types)) { + if (!type.name) { + continue + } + const categoryName = type.category || NO_CATEGORY_ID + + if (!out[categoryName]) { + out[categoryName] = [] + } + out[categoryName].push(type) + } + + return out +} + +export function generateAddContextMenuItems (types, onItemClick) { + const categories = orderTypesByCategory(types) + + return Object.entries(categories) + /* + Make sure items that don't belong to + a category are rendered first + */ + .sort((a) => a[0] === NO_CATEGORY_ID ? -1 : 1) + + .map(([id, category]) => { + /* + Render single items that don't + belong to any specific category + */ + if (id === NO_CATEGORY_ID) { + return category.map(type => { + return { + type: 'item', + label: type.name, + onClick: () => onItemClick(type.id) + } + }) + } + + /* + Render each named + category as a submenu + */ + return { + type: 'item', + label: id, + children: category.map(type => { + return { + type: 'item', + label: type.name, + onClick: () => onItemClick(type.id) + } + }) + } + }) + .flat(1) +} diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index fb781964..22e8f116 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -12,6 +12,7 @@ import { ContextMenuDivider } from '../../../../app/components/ContextMenuDivide import * as config from '../config' import * as clipboard from '../utils/clipboard' +import * as contextMenu from '../utils/contextMenu' export function Rundown () { const [shared] = React.useContext(SharedContext) @@ -21,13 +22,28 @@ export function Rundown () { const rundownId = window.WIDGET_DATA?.['rundown.id'] || config.DEFAULT_RUNDOWN_ID - function handleContextMenu (e) { - e.preventDefault() - setContextPos([e.pageX, e.pageY]) + async function handleItemCreate (typeId) { + const itemId = await bridge.items.createItem(typeId) + bridge.commands.executeCommand('rundown.appendItem', rundownId, itemId) } - async function handleItemCreate (itemId) { - bridge.commands.executeCommand('rundown.appendItem', rundownId, itemId) + async function handleContextMenu (e) { + e.preventDefault() + + const types = await bridge.state.get('_types') + bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY }, [ + { + type: 'item', + label: 'Paste', + fn: () => handlePaste() + }, + { type: 'divider' }, + { + type: 'item', + label: 'Add', + children: contextMenu.generateAddContextMenuItems(types, typeId => handleItemCreate(typeId)) + } + ]) } /** From 274dd3b51be6cff933f1471557283c6a5a654e6c Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 12 Nov 2025 23:27:27 +0100 Subject: [PATCH 2/5] Add a clipboard api Signed-off-by: Axel Boberg --- api/browser/client.js | 6 +++ api/browser/clipboard.js | 42 +++++++++++++++++++ .../app/components/RundownListItem/index.jsx | 7 ++-- plugins/rundown/app/utils/clipboard.js | 35 ---------------- plugins/rundown/app/utils/selection.js | 4 +- plugins/rundown/app/views/Rundown.jsx | 5 +-- 6 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 api/browser/clipboard.js delete mode 100644 plugins/rundown/app/utils/clipboard.js diff --git a/api/browser/client.js b/api/browser/client.js index 41dbbcb1..9cc30379 100644 --- a/api/browser/client.js +++ b/api/browser/client.js @@ -9,6 +9,7 @@ const InvalidArgumentError = require('../error/InvalidArgumentError') const LazyValue = require('../../shared/LazyValue') const DIController = require('../../shared/DIController') +require('./clipboard') require('./selection') /** @@ -41,6 +42,10 @@ class Client { return this.#props.Selection } + get clipboard () { + return this.#props.Clipboard + } + constructor (props) { this.#props = props this.#props.Selection.client = this @@ -191,5 +196,6 @@ DIController.main.register('Client', Client, [ 'State', 'Events', 'Commands', + 'Clipboard', 'Selection' ]) diff --git a/api/browser/clipboard.js b/api/browser/clipboard.js new file mode 100644 index 00000000..4073b7dc --- /dev/null +++ b/api/browser/clipboard.js @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Axel Boberg +// +// SPDX-License-Identifier: MIT + +const DIController = require('../../shared/DIController') + +class Clipboard { + /** + * Write a string into the clipboard + * @param { String } str A string to write + * @returns { Promise. } + */ + writeText (str) { + return navigator.clipboard.writeText(str) + } + + /** + * Read a string stored in the clipboard, + * will return an empty string + * if the clipboard is empty + * @returns { Promise. } + */ + readText () { + return navigator.clipboard.readText() + } + + /** + * Read the contents of the clipboard as a json object, + * will return undefined if unable to parse the data + * @returns { Promise. } + */ + async readJson () { + try { + const str = await this.readText() + return JSON.parse(str) + } catch (_) { + return undefined + } + } +} + +DIController.main.register('Clipboard', Clipboard) diff --git a/plugins/rundown/app/components/RundownListItem/index.jsx b/plugins/rundown/app/components/RundownListItem/index.jsx index 6bce4a95..6734d772 100644 --- a/plugins/rundown/app/components/RundownListItem/index.jsx +++ b/plugins/rundown/app/components/RundownListItem/index.jsx @@ -6,7 +6,6 @@ import './style.css' import { SharedContext } from '../../sharedContext' import * as contextMenu from '../../utils/contextMenu' -import * as clipboard from '../../utils/clipboard' import * as selection from '../../utils/selection' const INDICATE_PLAYING_TIMEOUT_MS = 100 @@ -148,12 +147,12 @@ export function RundownListItem ({ async function handleCopy () { const selection = await bridge.client.selection.getSelection() const string = await bridge.commands.executeCommand('rundown.copyItems', selection) - clipboard.copyText(string) + bridge.client.clipboard.writeText(string) } function handleCopyId () { const string = item.id - clipboard.copyText(string) + bridge.client.clipboard.writeText(string) } async function handleAdd (typeId) { @@ -191,7 +190,7 @@ export function RundownListItem ({ }, [item?.state, item?.didStartPlayingAt]) async function handlePaste () { - const items = await clipboard.readJson() + const items = await bridge.client.clipboard.readJson() bridge.commands.executeCommand('rundown.pasteItems', items, rundownId, index + 1) } diff --git a/plugins/rundown/app/utils/clipboard.js b/plugins/rundown/app/utils/clipboard.js deleted file mode 100644 index f730833d..00000000 --- a/plugins/rundown/app/utils/clipboard.js +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Sveriges Television AB -// -// SPDX-License-Identifier: MIT - -/** - * Copy a string into the clipboard - * @param { String } str A string to copy - * @returns { Promise. } - */ -export function copyText (str) { - return navigator.clipboard.writeText(str) -} - -/** - * Read the string stored in the clipboard, - * will return an empty string if the clipboard is empty - * @returns { Promise. } - */ -export function readText () { - return navigator.clipboard.readText() -} - -/** - * Read the contents of the clipboard as a json object, - * will return undefined if unable to parse the data - * @returns { Promise. } - */ -export async function readJson () { - try { - const str = await readText() - return JSON.parse(str) - } catch (_) { - return undefined - } -} diff --git a/plugins/rundown/app/utils/selection.js b/plugins/rundown/app/utils/selection.js index 643ae621..7e652052 100644 --- a/plugins/rundown/app/utils/selection.js +++ b/plugins/rundown/app/utils/selection.js @@ -1,7 +1,5 @@ import bridge from 'bridge' -import * as clipboard from './clipboard' - /** * Toggle the disabled property * of the currently selected items @@ -70,7 +68,7 @@ export async function deleteSelection () { export async function copySelection () { const selection = await bridge.client.selection.getSelection() const str = await bridge.commands.executeCommand('rundown.copyItems', selection) - await clipboard.copyText(str) + await bridge.client.clipboard.writeText(str) } /** diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index 22e8f116..dbe3b65b 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -11,7 +11,6 @@ import { ContextMenuItem } from '../../../../app/components/ContextMenuItem' import { ContextMenuDivider } from '../../../../app/components/ContextMenuDivider' import * as config from '../config' -import * as clipboard from '../utils/clipboard' import * as contextMenu from '../utils/contextMenu' export function Rundown () { @@ -35,7 +34,7 @@ export function Rundown () { { type: 'item', label: 'Paste', - fn: () => handlePaste() + onClick: () => handlePaste() }, { type: 'divider' }, { @@ -82,7 +81,7 @@ export function Rundown () { } async function handlePaste () { - const items = await clipboard.readJson() + const items = await bridge.client.clipboard.readJson() const selection = await bridge.client.selection.getSelection() /* From c1cf09dd4a4f083cacd874c4fe96e6617a99623b Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 12 Nov 2025 23:33:57 +0100 Subject: [PATCH 3/5] Remove old code and make the add button of the rundown use the new context menu api Signed-off-by: Axel Boberg --- .../app/components/ContextAddMenu/index.jsx | 79 ------------------- .../rundown/app/components/Header/index.jsx | 59 ++++++-------- .../app/components/RundownList/index.jsx | 1 - plugins/rundown/app/views/Rundown.jsx | 19 ----- 4 files changed, 25 insertions(+), 133 deletions(-) delete mode 100644 plugins/rundown/app/components/ContextAddMenu/index.jsx diff --git a/plugins/rundown/app/components/ContextAddMenu/index.jsx b/plugins/rundown/app/components/ContextAddMenu/index.jsx deleted file mode 100644 index 15450907..00000000 --- a/plugins/rundown/app/components/ContextAddMenu/index.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react' -import bridge from 'bridge' - -import { SharedContext } from '../../sharedContext' - -import { ContextMenuItem } from '../../../../../app/components/ContextMenuItem' - -const NO_CATEGORY_ID = '__none__' - -export function ContextAddMenu ({ onAdd = () => {} }) { - const [shared] = React.useContext(SharedContext) - - async function handleClick (typeId) { - const itemId = await bridge.items.createItem(typeId) - onAdd(itemId) - } - - /** - * An object holding the different categories - * for the types, non-duplicated - * @type { Object. } - */ - const categories = React.useMemo(() => { - if (!shared?._types) { - return {} - } - const out = {} - - for (const type of Object.values(shared?._types)) { - if (!type.name) { - continue - } - const categoryName = type.category || NO_CATEGORY_ID - if (!out[categoryName]) { - out[categoryName] = [] - } - out[categoryName].push(type) - } - return out - }, [shared?._types]) - - return ( - <> - { - Object.entries(categories || {}) - /* - Make sure items that don't belong to - a category are rendered first - */ - .sort((a) => a[0] === NO_CATEGORY_ID ? -1 : 1) - .map(([id, category]) => { - /* - Render single items that don't - belong to any specific category - */ - if (id === NO_CATEGORY_ID) { - return category.map(type => - handleClick(type.id)} /> - ) - } - - /* - Render categories - as nested menus - */ - return ( - - { - category.map(type => - handleClick(type.id)} /> - ) - } - - ) - }) - } - - ) -} diff --git a/plugins/rundown/app/components/Header/index.jsx b/plugins/rundown/app/components/Header/index.jsx index 1e424717..b2ac3340 100644 --- a/plugins/rundown/app/components/Header/index.jsx +++ b/plugins/rundown/app/components/Header/index.jsx @@ -4,27 +4,29 @@ import bridge from 'bridge' import './style.css' import { Icon } from '../../../../../app/components/Icon' -import { ContextMenu } from '../../../../../app/components/ContextMenu' - -import { ContextAddMenu } from '../ContextAddMenu' import * as config from '../../config' +import * as contextMenu from '../../utils/contextMenu' export function Header () { const [rundownInfo, setRundownInfo] = React.useState() - const [contextPos, setContextPos] = React.useState() - function handleCreateOnClick (e) { + async function handleCreateOnClick (e) { e.preventDefault() - setContextPos([e.pageX, e.pageY]) + + const types = await bridge.state.get('_types') + const spec = contextMenu.generateAddContextMenuItems(types, typeId => handleAdd(typeId)) + + bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY }, spec) } /** * Add a new item to the * end of the rundown */ - async function handleAdd (newItemId) { - bridge.commands.executeCommand('rundown.appendItem', rundownInfo?.id, newItemId) + async function handleAdd (typeId) { + const itemId = await bridge.items.createItem(typeId) + bridge.commands.executeCommand('rundown.appendItem', rundownInfo?.id, itemId) } /** @@ -73,32 +75,21 @@ export function Header () { }, []) return ( - <> - { - contextPos - ? ( - setContextPos(undefined)}> - handleAdd(newItemId)} /> - - ) - : <> - } -
-
- -
-
-
- handleLoadMainRundown()}>Main rundown - { - rundownInfo?.name && - / {rundownInfo?.name} - } -
+
+
+ +
+
+
+ handleLoadMainRundown()}>Main rundown + { + rundownInfo?.name && + / {rundownInfo?.name} + }
-
- +
+
) } diff --git a/plugins/rundown/app/components/RundownList/index.jsx b/plugins/rundown/app/components/RundownList/index.jsx index 83019698..4a74947b 100644 --- a/plugins/rundown/app/components/RundownList/index.jsx +++ b/plugins/rundown/app/components/RundownList/index.jsx @@ -86,7 +86,6 @@ export function RundownList ({ const [shared] = React.useContext(SharedContext) const elRef = React.useRef() - const focusRef = React.useRef() const itemIds = shared?.items?.[rundownId]?.children || [] diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index dbe3b65b..9632ad0a 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -2,13 +2,7 @@ import React from 'react' import bridge from 'bridge' import { SharedContext } from '../sharedContext' - import { RundownList } from '../components/RundownList' -import { ContextAddMenu } from '../components/ContextAddMenu' - -import { ContextMenu } from '../../../../app/components/ContextMenu' -import { ContextMenuItem } from '../../../../app/components/ContextMenuItem' -import { ContextMenuDivider } from '../../../../app/components/ContextMenuDivider' import * as config from '../config' import * as contextMenu from '../utils/contextMenu' @@ -150,19 +144,6 @@ export function Rundown () { return (
handleContextMenu(e)}> - { - contextPos - ? ( - setContextPos(undefined)}> - handlePaste()} /> - - - handleItemCreate(newItemId)}/> - - - ) - : <> - }
) From e66ec673e7df35b8a5712012bdc9b1af4da11438 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 12 Nov 2025 23:39:59 +0100 Subject: [PATCH 4/5] Make nested context menus get keys Signed-off-by: Axel Boberg --- app/components/ContextMenuBoundary/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index 43b764ee..00717dfd 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -59,7 +59,7 @@ function renderItemSpec (spec, key) { handleClick()}> { (spec?.children || []) - .map((child, i) => renderItemSpec(child, i)) + .map((child, i) => renderItemSpec(child, `${key}_${i}`)) } ) @@ -121,8 +121,8 @@ export function ContextMenuBoundary ({ children }) { handleClose()}> { Array.isArray(spec) - ? spec.map((item, i) => renderItemSpec(item, `spec_${i}`)) - : renderItemSpec(spec) + ? spec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`)) + : renderItemSpec(spec, id) } ) From 167316447df954f5a1a7946d4d019cc77a20643f Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 00:01:00 +0100 Subject: [PATCH 5/5] Make sure context menus close when they should Signed-off-by: Axel Boberg --- api/browser/ui/contextMenu.js | 27 +++++++++++++++++++- app/components/ContextMenuBoundary/index.jsx | 27 +++++++++++++++++--- app/components/FrameComponent/index.jsx | 15 +++++++++-- plugins/rundown/app/App.jsx | 1 + 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js index efc9970b..b89a4f83 100644 --- a/api/browser/ui/contextMenu.js +++ b/api/browser/ui/contextMenu.js @@ -4,15 +4,40 @@ const DIController = require('../../../shared/DIController') +/** + * A threshold for how long the context menu has + * to have been open before an event can close it + * + * This it to prevent the same event to + * both open and close a context menu + * + * @type { Number } + */ +const OPEN_THRESHOLD_MS = 100 + class UIContextMenu { #props + #openedAt constructor (props) { this.#props = props } + close () { + /* + Check how long the context menu has been opened + to prevent it from closing on the same event that + opened it + */ + if (Date.now() - this.#openedAt <= OPEN_THRESHOLD_MS) { + return + } + this.#props.Events.emitLocally('ui.contextMenu.close') + } + open (opts, spec) { - this.#props.Events.emitLocally('ui.contextMenu', opts, spec) + this.#openedAt = Date.now() + this.#props.Events.emitLocally('ui.contextMenu.open', opts, spec) } } diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index 00717dfd..ecee6ad1 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -96,7 +96,7 @@ export function ContextMenuBoundary ({ children }) { async function setup () { bridge = await api.load() - bridge.events.on('ui.contextMenu', onRequestContextMenu) + bridge.events.on('ui.contextMenu.open', onRequestContextMenu) } setup() @@ -104,7 +104,28 @@ export function ContextMenuBoundary ({ children }) { if (!bridge) { return } - bridge.events.off('ui.contextMenu', onRequestContextMenu) + bridge.events.off('ui.contextMenu.open', onRequestContextMenu) + } + }, []) + + React.useEffect(() => { + let bridge + + function onContextMenuClose () { + setContextPos(undefined) + } + + async function setup () { + bridge = await api.load() + bridge.events.on('ui.contextMenu.close', onContextMenuClose) + } + setup() + + return () => { + if (!bridge) { + return + } + bridge.events.off('ui.contextMenu.close', onContextMenuClose) } }, []) @@ -122,7 +143,7 @@ export function ContextMenuBoundary ({ children }) { { Array.isArray(spec) ? spec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`)) - : renderItemSpec(spec, id) + : renderItemSpec(spec, 'contextMenu') } ) diff --git a/app/components/FrameComponent/index.jsx b/app/components/FrameComponent/index.jsx index 65c8b873..e36bc12e 100644 --- a/app/components/FrameComponent/index.jsx +++ b/app/components/FrameComponent/index.jsx @@ -194,12 +194,22 @@ export function FrameComponent ({ data, onUpdate, enableFloat = true }) { if (!contentWindow) { return } - function onFocus () { + + async function onClick () { + const bridge = await api.load() + bridge.ui.contextMenu.close() + } + contentWindow.addEventListener('click', onClick) + + async function onFocus () { setHasFocus(true) contentWindow.bridgeFrameHasFocus = true + + const bridge = await api.load() + bridge.ui.contextMenu.close() } contentWindow.addEventListener('focus', onFocus) - + function onBlur () { setHasFocus(false) contentWindow.bridgeFrameHasFocus = false @@ -207,6 +217,7 @@ export function FrameComponent ({ data, onUpdate, enableFloat = true }) { contentWindow.addEventListener('blur', onBlur) return () => { + contentWindow.removeEventListener('click', onClick) contentWindow.removeEventListener('focus', onFocus) contentWindow.removeEventListener('blur', onBlur) } diff --git a/plugins/rundown/app/App.jsx b/plugins/rundown/app/App.jsx index f2226617..a81ade16 100644 --- a/plugins/rundown/app/App.jsx +++ b/plugins/rundown/app/App.jsx @@ -1,4 +1,5 @@ import React from 'react' +import bridge from 'bridge' import { SharedContextProvider } from './sharedContext'