From 3aed1ebd4f1e878039b3083835e04064884fe448 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 00:08:09 +0100 Subject: [PATCH 1/6] Update CHANGELOG Signed-off-by: Axel Boberg --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c4176..00d8d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ ### Fixed - The space key can now be used for keyboard shortcuts - An issue where items in the rundown couldn't rapidly be selected and de-selected +- An issue where context menus were cut off in the rundown ### Added - Support for selecting multiple items at once with the shift key +- An API for managing context menus +- An API for managing the clipboard ## 1.0.0-beta.7 ### Changed From d360a3e3e1aa7fe4be99f8319650240d7bdb3c80 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 22:09:25 +0100 Subject: [PATCH 2/6] Fix issues with the palette Signed-off-by: Axel Boberg --- CHANGELOG.md | 2 + app/components/Palette/index.jsx | 37 +++++++++---------- app/components/Palette/integrations/items.jsx | 8 +++- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d8d64..5456817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - The space key can now be used for keyboard shortcuts - An issue where items in the rundown couldn't rapidly be selected and de-selected - An issue where context menus were cut off in the rundown +- An issue with the palette not setting proper keys +- An issue with the palette not removing event listeners ### Added - Support for selecting multiple items at once with the shift key - An API for managing context menus diff --git a/app/components/Palette/index.jsx b/app/components/Palette/index.jsx index 77e33ae..bc86673 100644 --- a/app/components/Palette/index.jsx +++ b/app/components/Palette/index.jsx @@ -185,26 +185,23 @@ export const Palette = ({ open, onClose = () => {} }) => { */ result .filter(({ rows }) => rows.length) - .map(({ label, rows }) => { - return ( - <> - - { - rows.map((row, i) => { - return ( -
onClose()} - onKeyDown={e => handleRowKeyDown(e)} - tabIndex={0} - > - {row} -
- ) - }) - } - + .flatMap(({ label, rows }) => { + return ([ + , + rows.flatMap((row, i) => { + return ( +
onClose()} + onKeyDown={e => handleRowKeyDown(e)} + tabIndex={0} + > + {row} +
+ ) + }) + ] ) }) } diff --git a/app/components/Palette/integrations/items.jsx b/app/components/Palette/integrations/items.jsx index 3c2efb8..659d15b 100644 --- a/app/components/Palette/integrations/items.jsx +++ b/app/components/Palette/integrations/items.jsx @@ -16,7 +16,7 @@ function ItemRow ({ item }) { otherwise the shortcut shouldn't be performed */ - if (document.activeElement !== elRef.current.parentElement) { + if (document.activeElement !== elRef.current?.parentElement) { return } @@ -32,13 +32,17 @@ function ItemRow ({ item }) { } } + let bridge async function setup () { - const bridge = await api.load() + bridge = await api.load() bridge.events.on('shortcut', onShortcut) } setup() return () => { + if (!bridge) { + return + } bridge.events.off('shortcut', onShortcut) } }, [item]) From 7eedfb050069afd80e83c849dadba618b06d0bae Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 22:48:40 +0100 Subject: [PATCH 3/6] Add a searchable option for context menus Signed-off-by: Axel Boberg --- app/components/ContextMenu/index.jsx | 22 +++++- app/components/ContextMenu/style.css | 3 - app/components/ContextMenuBoundary/index.jsx | 37 ++++++++-- app/components/ContextMenuItem/style.css | 9 ++- app/components/ContextSearchItem/index.jsx | 68 +++++++++++++++++++ app/components/ContextSearchItem/style.css | 12 ++++ .../rundown/app/components/Header/index.jsx | 2 +- plugins/rundown/app/views/Rundown.jsx | 2 +- 8 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 app/components/ContextSearchItem/index.jsx create mode 100644 app/components/ContextSearchItem/style.css diff --git a/app/components/ContextMenu/index.jsx b/app/components/ContextMenu/index.jsx index cb9aa65..fcaffd8 100644 --- a/app/components/ContextMenu/index.jsx +++ b/app/components/ContextMenu/index.jsx @@ -10,11 +10,23 @@ import './style.css' * This it to prevent the same event to * both open and close a context menu * - * @type { Number } + * @type { number } */ const OPEN_THRESHOLD_MS = 100 -export const ContextMenu = ({ x, y, children, onClose = () => {} }) => { +/** + * The default width of + * a context menu in pixels, + * + * will be used unless a new width + * is specified as a property + * to the component + * + * @type { number } + */ +const DEFAULT_WIDTH_PX = 150 + +export const ContextMenu = ({ x, y, width = DEFAULT_WIDTH_PX, children, onClose = () => {} }) => { const elRef = React.useRef() const openTimestampRef = React.useRef() @@ -64,7 +76,11 @@ export const ContextMenu = ({ x, y, children, onClose = () => {} }) => { <> { createPortal( -
+
{children}
, document.body diff --git a/app/components/ContextMenu/style.css b/app/components/ContextMenu/style.css index c795b45..fb362d0 100644 --- a/app/components/ContextMenu/style.css +++ b/app/components/ContextMenu/style.css @@ -1,8 +1,5 @@ - - .ContextMenu { position: fixed; - width: 150px; background: white; color: black; diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index ecee6ad..8239c4d 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -4,6 +4,7 @@ import * as api from '../../api' import { ContextMenu } from '../ContextMenu' import { ContextMenuItem } from '../ContextMenuItem' import { ContextMenuDivider } from '../ContextMenuDivider' +import { ContextMenuSearchItem } from '../ContextSearchItem' const TYPES = { item: ContextMenuItem, @@ -15,6 +16,8 @@ const ALLOWED_SPEC_PROPERTIES = [ 'label' ] +const MENU_WIDTH_IF_SEARCH_PX = 250 + function isNumber (x) { return typeof x === 'number' && !Number.isNaN(x) } @@ -67,7 +70,10 @@ function renderItemSpec (spec, key) { export function ContextMenuBoundary ({ children }) { const [contextPos, setContextPos] = React.useState() - const [spec, setSpec] = React.useState() + + const [originalSpec, setOriginalSpec] = React.useState() + const [renderedSpec, setRenderedSpec] = React.useState() + const [opts, setOpts] = React.useState() React.useEffect(() => { let bridge @@ -91,7 +97,9 @@ export function ContextMenuBoundary ({ children }) { y: Math.max(pageCoords.y, 0) }) - setSpec(spec) + setOpts(opts) + setRenderedSpec(spec) + setOriginalSpec(spec) } async function setup () { @@ -131,7 +139,13 @@ export function ContextMenuBoundary ({ children }) { function handleClose () { setContextPos(undefined) - setSpec(undefined) + + setOriginalSpec(undefined) + setRenderedSpec(undefined) + } + + function handleSearch (newSpec) { + setRenderedSpec(newSpec) } return ( @@ -139,11 +153,20 @@ export function ContextMenuBoundary ({ children }) { { contextPos && ( - handleClose()}> + handleClose()} + > + { + opts?.searchable && + handleSearch(newSpec)} /> + } { - Array.isArray(spec) - ? spec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`)) - : renderItemSpec(spec, 'contextMenu') + Array.isArray(renderedSpec) + ? renderedSpec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`)) + : renderItemSpec(renderedSpec, 'contextMenu') } ) diff --git a/app/components/ContextMenuItem/style.css b/app/components/ContextMenuItem/style.css index 7639b57..f687b7a 100644 --- a/app/components/ContextMenuItem/style.css +++ b/app/components/ContextMenuItem/style.css @@ -4,8 +4,15 @@ } .ContextMenuItem-text { - padding: 0.5em; + padding: 0.4em 0.5em; border-radius: 4px; + + font-size: 0.95em; + + text-overflow: ellipsis; + white-space: nowrap; + + overflow: hidden; } .ContextMenuItem .Icon { diff --git a/app/components/ContextSearchItem/index.jsx b/app/components/ContextSearchItem/index.jsx new file mode 100644 index 0000000..1906904 --- /dev/null +++ b/app/components/ContextSearchItem/index.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import './style.css' + +import { ContextMenuDivider } from '../ContextMenuDivider' + +function flattenSpec (spec, parentLabel) { + const out = [] + for (const item of spec) { + if (typeof item !== 'object') { + continue + } + + let newLabel = item?.label + if (parentLabel) { + newLabel = `${parentLabel} > ${item?.label}` + } + + out.push({ + ...item, + label: newLabel, + children: undefined + }) + + if (Array.isArray(item.children)) { + out.push(...flattenSpec(item.children, newLabel)) + } + } + return out +} + +export const ContextMenuSearchItem = ({ spec = [], onSearch = () => {} }) => { + const flattened = React.useMemo(() => { + return flattenSpec(spec) + }, [spec]) + + function handleChange (e) { + const query = (e.target.value || '').toLowerCase() + + if (!query) { + onSearch(spec) + return + } + + const newSpec = flattened + .filter(item => item?.onClick) + .filter(item => { + return (item?.label || '').toLowerCase().indexOf(query) > -1 + }) + onSearch(newSpec) + } + + return ( + <> +
+ handleChange(e)} + placeholder=' Search' + autoFocus + /> +
+ + + ) +} diff --git a/app/components/ContextSearchItem/style.css b/app/components/ContextSearchItem/style.css new file mode 100644 index 0000000..47f3f01 --- /dev/null +++ b/app/components/ContextSearchItem/style.css @@ -0,0 +1,12 @@ +.ContextMenuSearchItem { + padding: 0; +} + +.ContextMenuSearchItem input.ContextMenuSearchItem-input{ + width: 100%; + + background: none; + + border-radius: 0; + box-shadow: none; +} \ No newline at end of file diff --git a/plugins/rundown/app/components/Header/index.jsx b/plugins/rundown/app/components/Header/index.jsx index b2ac334..d42e124 100644 --- a/plugins/rundown/app/components/Header/index.jsx +++ b/plugins/rundown/app/components/Header/index.jsx @@ -17,7 +17,7 @@ export function Header () { 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) + bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY, searchable: true }, spec) } /** diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index 9632ad0..d063f35 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -24,7 +24,7 @@ export function Rundown () { e.preventDefault() const types = await bridge.state.get('_types') - bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY }, [ + bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY, searchable: true }, [ { type: 'item', label: 'Paste', From dc068003a842c8ebd7ad44b5a8eafc025d7ccd21 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 22:50:49 +0100 Subject: [PATCH 4/6] Switch places for the spec and opts parameters in the context menu api Signed-off-by: Axel Boberg --- api/browser/ui/contextMenu.js | 4 ++-- app/components/ContextMenuBoundary/index.jsx | 2 +- plugins/rundown/app/components/Header/index.jsx | 2 +- plugins/rundown/app/components/RundownListItem/index.jsx | 2 +- plugins/rundown/app/views/Rundown.jsx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js index b89a4f8..cef41f5 100644 --- a/api/browser/ui/contextMenu.js +++ b/api/browser/ui/contextMenu.js @@ -35,9 +35,9 @@ class UIContextMenu { this.#props.Events.emitLocally('ui.contextMenu.close') } - open (opts, spec) { + open (spec, opts) { this.#openedAt = Date.now() - this.#props.Events.emitLocally('ui.contextMenu.open', opts, spec) + this.#props.Events.emitLocally('ui.contextMenu.open', spec, opts) } } diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index 8239c4d..3fe1358 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -78,7 +78,7 @@ export function ContextMenuBoundary ({ children }) { React.useEffect(() => { let bridge - function onRequestContextMenu (opts, spec) { + function onRequestContextMenu (spec, opts) { if (!isNumber(opts?.x) || !isNumber(opts?.y)) { console.warn('Missing context menu position') return diff --git a/plugins/rundown/app/components/Header/index.jsx b/plugins/rundown/app/components/Header/index.jsx index d42e124..48df5b7 100644 --- a/plugins/rundown/app/components/Header/index.jsx +++ b/plugins/rundown/app/components/Header/index.jsx @@ -17,7 +17,7 @@ export function Header () { 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, searchable: true }, spec) + bridge.ui.contextMenu.open(spec, { x: e.screenX, y: e.screenY, searchable: true }) } /** diff --git a/plugins/rundown/app/components/RundownListItem/index.jsx b/plugins/rundown/app/components/RundownListItem/index.jsx index 6734d77..77f3348 100644 --- a/plugins/rundown/app/components/RundownListItem/index.jsx +++ b/plugins/rundown/app/components/RundownListItem/index.jsx @@ -136,7 +136,7 @@ export function RundownListItem ({ ) ] - bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY }, spec) + bridge.ui.contextMenu.open(spec, { x: e.screenX, y: e.screenY }) } async function handleDelete () { diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index d063f35..3290d28 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -24,7 +24,7 @@ export function Rundown () { e.preventDefault() const types = await bridge.state.get('_types') - bridge.ui.contextMenu.open({ x: e.screenX, y: e.screenY, searchable: true }, [ + bridge.ui.contextMenu.open([ { type: 'item', label: 'Paste', @@ -36,7 +36,7 @@ export function Rundown () { label: 'Add', children: contextMenu.generateAddContextMenuItems(types, typeId => handleItemCreate(typeId)) } - ]) + ], { x: e.screenX, y: e.screenY, searchable: true }) } /** From 9a824a3fb6347e1a4116981f17e88934599a27d4 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 23:07:26 +0100 Subject: [PATCH 5/6] Make items in context menus selectable with tab Signed-off-by: Axel Boberg --- app/components/ContextMenu/index.jsx | 12 ++++++++++++ app/components/ContextMenuBoundary/index.jsx | 9 +++++---- app/components/ContextMenuItem/index.jsx | 13 +++++++++++++ app/components/ContextMenuItem/style.css | 4 ++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/components/ContextMenu/index.jsx b/app/components/ContextMenu/index.jsx index fcaffd8..c51e4e5 100644 --- a/app/components/ContextMenu/index.jsx +++ b/app/components/ContextMenu/index.jsx @@ -59,6 +59,18 @@ export const ContextMenu = ({ x, y, width = DEFAULT_WIDTH_PX, children, onClose } }, [x, y, onClose]) + React.useEffect(() => { + function closeContext (e) { + if (e.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', closeContext) + return () => { + window.removeEventListener('keydown', closeContext) + } + }, [x, y, onClose]) + /* Make sure that the menu open in the direction where it's got the most free space diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index 3fe1358..bdb8f55 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -44,7 +44,7 @@ function sanitizeItemSpec (spec) { return out } -function renderItemSpec (spec, key) { +function renderItemSpec (spec, key, onClose = () => {}) { if (!TYPES[spec?.type]) { return <> } @@ -56,13 +56,14 @@ function renderItemSpec (spec, key) { return } spec.onClick() + onClose() } return ( handleClick()}> { (spec?.children || []) - .map((child, i) => renderItemSpec(child, `${key}_${i}`)) + .map((child, i) => renderItemSpec(child, `${key}_${i}`, onClose)) } ) @@ -165,8 +166,8 @@ export function ContextMenuBoundary ({ children }) { } { Array.isArray(renderedSpec) - ? renderedSpec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`)) - : renderItemSpec(renderedSpec, 'contextMenu') + ? renderedSpec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`, handleClose)) + : renderItemSpec(renderedSpec, 'contextMenu', handleClose) }
) diff --git a/app/components/ContextMenuItem/index.jsx b/app/components/ContextMenuItem/index.jsx index d9e873f..791bc5e 100644 --- a/app/components/ContextMenuItem/index.jsx +++ b/app/components/ContextMenuItem/index.jsx @@ -38,6 +38,16 @@ export const ContextMenuItem = ({ text, children = [], onClick = () => {} }) => }, MOUSE_LEAVE_DELAY_MS) } + function handleKeyDown (e) { + if (e.key === 'Enter') { + onClick() + } + } + + function handleFocus (e) { + setHover(true) + } + const bounds = elRef.current?.getBoundingClientRect() return ( @@ -47,6 +57,9 @@ export const ContextMenuItem = ({ text, children = [], onClick = () => {} }) => onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={() => onClick()} + onKeyDown={e => handleKeyDown(e)} + onFocus={e => handleFocus(e)} + tabIndex={0} >
{text} diff --git a/app/components/ContextMenuItem/style.css b/app/components/ContextMenuItem/style.css index f687b7a..6bc2622 100644 --- a/app/components/ContextMenuItem/style.css +++ b/app/components/ContextMenuItem/style.css @@ -3,6 +3,10 @@ padding: 0.2em; } +.ContextMenuItem:focus .ContextMenuItem-text { + box-shadow: inset 0 0 0 2px var(--base-color--shade); +} + .ContextMenuItem-text { padding: 0.4em 0.5em; border-radius: 4px; From dc78f1657c2de2dc7f22109add18235398eae81f Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Thu, 13 Nov 2025 23:08:06 +0100 Subject: [PATCH 6/6] Update changelog Signed-off-by: Axel Boberg --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5456817..5fba40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Support for selecting multiple items at once with the shift key - An API for managing context menus - An API for managing the clipboard +- Search in context menus +- Keyboard control in context menus ## 1.0.0-beta.7 ### Changed