Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions api/browser/ui/contextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
34 changes: 31 additions & 3 deletions app/components/ContextMenu/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -64,7 +88,11 @@ export const ContextMenu = ({ x, y, children, onClose = () => {} }) => {
<>
{
createPortal(
<div ref={elRef} className={`ContextMenu u-theme--light ContextMenu--${direction}`} style={{ top: y, left: x }}>
<div
ref={elRef}
className={`ContextMenu u-theme--light ContextMenu--${direction}`}
style={{ top: y, left: x, width: width }}
>
{children}
</div>,
document.body
Expand Down
3 changes: 0 additions & 3 deletions app/components/ContextMenu/style.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@


.ContextMenu {
position: fixed;
width: 150px;

background: white;
color: black;
Expand Down
44 changes: 34 additions & 10 deletions app/components/ContextMenuBoundary/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
Expand All @@ -41,7 +44,7 @@ function sanitizeItemSpec (spec) {
return out
}

function renderItemSpec (spec, key) {
function renderItemSpec (spec, key, onClose = () => {}) {
if (!TYPES[spec?.type]) {
return <></>
}
Expand All @@ -53,26 +56,30 @@ function renderItemSpec (spec, key) {
return
}
spec.onClick()
onClose()
}

return (
<Component key={key} {...sanitizeItemSpec(spec)} text={spec?.label} onClick={() => handleClick()}>
{
(spec?.children || [])
.map((child, i) => renderItemSpec(child, `${key}_${i}`))
.map((child, i) => renderItemSpec(child, `${key}_${i}`, onClose))
}
</Component>
)
}

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
Expand All @@ -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 () {
Expand Down Expand Up @@ -131,19 +140,34 @@ export function ContextMenuBoundary ({ children }) {

function handleClose () {
setContextPos(undefined)
setSpec(undefined)

setOriginalSpec(undefined)
setRenderedSpec(undefined)
}

function handleSearch (newSpec) {
setRenderedSpec(newSpec)
}

return (
<>
{
contextPos &&
(
<ContextMenu x={contextPos.x} y={contextPos.y} onClose={() => handleClose()}>
<ContextMenu
x={contextPos.x}
y={contextPos.y}
width={opts?.searchable && MENU_WIDTH_IF_SEARCH_PX}
onClose={() => handleClose()}
>
{
opts?.searchable &&
<ContextMenuSearchItem spec={originalSpec} onSearch={newSpec => 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)
}
</ContextMenu>
)
Expand Down
13 changes: 13 additions & 0 deletions app/components/ContextMenuItem/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}
>
<div className='ContextMenuItem-text'>
{text}
Expand Down
13 changes: 12 additions & 1 deletion app/components/ContextMenuItem/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions app/components/ContextSearchItem/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div
className='ContextMenuItem ContextMenuSearchItem'
>
<input
type='search'
className='ContextMenuSearchItem-input'
onChange={e => handleChange(e)}
placeholder='&#xe900; Search'
autoFocus
/>
</div>
<ContextMenuDivider />
</>
)
}
12 changes: 12 additions & 0 deletions app/components/ContextSearchItem/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.ContextMenuSearchItem {
padding: 0;
}

.ContextMenuSearchItem input.ContextMenuSearchItem-input{
width: 100%;

background: none;

border-radius: 0;
box-shadow: none;
}
37 changes: 17 additions & 20 deletions app/components/Palette/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,26 +185,23 @@ export const Palette = ({ open, onClose = () => {} }) => {
*/
result
.filter(({ rows }) => rows.length)
.map(({ label, rows }) => {
return (
<>
<label key={label} className='Palette-resultLabel u-text--label'>{label}</label>
{
rows.map((row, i) => {
return (
<div
key={`${label}:${i}`}
className='Palette-row is-selectable'
onClick={() => onClose()}
onKeyDown={e => handleRowKeyDown(e)}
tabIndex={0}
>
{row}
</div>
)
})
}
</>
.flatMap(({ label, rows }) => {
return ([
<label key={label} className='Palette-resultLabel u-text--label'>{label}</label>,
rows.flatMap((row, i) => {
return (
<div
key={`${label}:${i}`}
className='Palette-row is-selectable'
onClick={() => onClose()}
onKeyDown={e => handleRowKeyDown(e)}
tabIndex={0}
>
{row}
</div>
)
})
]
)
})
}
Expand Down
Loading