diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c41769..5fba40d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,15 @@ ### 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 +- 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 +- An API for managing the clipboard +- Search in context menus +- Keyboard control in context menus ## 1.0.0-beta.7 ### Changed diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js index b89a4f83..cef41f54 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/ContextMenu/index.jsx b/app/components/ContextMenu/index.jsx index cb9aa653..c51e4e55 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() @@ -47,6 +59,18 @@ export const ContextMenu = ({ x, y, 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 @@ -64,7 +88,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 c795b454..fb362d07 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 ecee6ad1..bdb8f55f 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) } @@ -41,7 +44,7 @@ function sanitizeItemSpec (spec) { return out } -function renderItemSpec (spec, key) { +function renderItemSpec (spec, key, onClose = () => {}) { if (!TYPES[spec?.type]) { return <> } @@ -53,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)) } ) @@ -67,12 +71,15 @@ 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 - function onRequestContextMenu (opts, spec) { + function onRequestContextMenu (spec, opts) { if (!isNumber(opts?.x) || !isNumber(opts?.y)) { console.warn('Missing context menu position') return @@ -91,7 +98,9 @@ export function ContextMenuBoundary ({ children }) { y: Math.max(pageCoords.y, 0) }) - setSpec(spec) + setOpts(opts) + setRenderedSpec(spec) + setOriginalSpec(spec) } async function setup () { @@ -131,7 +140,13 @@ export function ContextMenuBoundary ({ children }) { function handleClose () { setContextPos(undefined) - setSpec(undefined) + + setOriginalSpec(undefined) + setRenderedSpec(undefined) + } + + function handleSearch (newSpec) { + setRenderedSpec(newSpec) } return ( @@ -139,11 +154,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}`, handleClose)) + : renderItemSpec(renderedSpec, 'contextMenu', handleClose) } ) diff --git a/app/components/ContextMenuItem/index.jsx b/app/components/ContextMenuItem/index.jsx index d9e873f5..791bc5ef 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 7639b575..6bc26224 100644 --- a/app/components/ContextMenuItem/style.css +++ b/app/components/ContextMenuItem/style.css @@ -3,9 +3,20 @@ padding: 0.2em; } +.ContextMenuItem:focus .ContextMenuItem-text { + box-shadow: inset 0 0 0 2px var(--base-color--shade); +} + .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 00000000..1906904b --- /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 00000000..47f3f01a --- /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/app/components/Palette/index.jsx b/app/components/Palette/index.jsx index 77e33aed..bc866737 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 3c2efb80..659d15b9 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]) diff --git a/plugins/rundown/app/components/Header/index.jsx b/plugins/rundown/app/components/Header/index.jsx index b2ac3340..48df5b79 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(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 6734d772..77f3348e 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 9632ad0a..3290d281 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([ { 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 }) } /**