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
6 changes: 6 additions & 0 deletions api/browser/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const InvalidArgumentError = require('../error/InvalidArgumentError')
const LazyValue = require('../../shared/LazyValue')
const DIController = require('../../shared/DIController')

require('./clipboard')
require('./selection')

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -191,5 +196,6 @@ DIController.main.register('Client', Client, [
'State',
'Events',
'Commands',
'Clipboard',
'Selection'
])
42 changes: 42 additions & 0 deletions api/browser/clipboard.js
Original file line number Diff line number Diff line change
@@ -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.<Boolean> }
*/
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.<String> }
*/
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.<Object | undefined> }
*/
async readJson () {
try {
const str = await this.readText()
return JSON.parse(str)
} catch (_) {
return undefined
}
}
}

DIController.main.register('Clipboard', Clipboard)
46 changes: 46 additions & 0 deletions api/browser/ui/contextMenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2025 Axel Boberg
//
// SPDX-License-Identifier: MIT

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.#openedAt = Date.now()
this.#props.Events.emitLocally('ui.contextMenu.open', opts, spec)
}
}

DIController.main.register('UIContextMenu', UIContextMenu, [
'Events'
])
17 changes: 17 additions & 0 deletions api/browser/ui/index.js
Original file line number Diff line number Diff line change
@@ -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'
])
5 changes: 4 additions & 1 deletion api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require('./system')
require('./state')
require('./types')
require('./items')
require('./ui')

class API {
constructor (props) {
Expand All @@ -35,6 +36,7 @@ class API {
this.state = props.State
this.types = props.Types
this.items = props.Items
this.ui = props.UI
}
}

Expand All @@ -52,7 +54,8 @@ DIController.main.register('API', API, [
'System',
'State',
'Types',
'Items'
'Items',
'UI'
])

const main = DIController.main.instantiate('API')
Expand Down
13 changes: 13 additions & 0 deletions api/node/ui.js
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions api/ui.js
Original file line number Diff line number Diff line change
@@ -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')
}
})()
34 changes: 19 additions & 15 deletions app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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'
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'
Expand Down Expand Up @@ -203,21 +205,23 @@ export default function App () {
<SocketContext.Provider value={[send, data]}>
<LocalContext.Provider value={[local, applyLocal]}>
<SharedContext.Provider value={[shared, applyShared]}>
<Transparency />
<Router routes={[
{
path: /^\/workspaces\/.+\/widgets\/.+$/,
render: () => <WorkspaceWidget />
},
{
path: /^\/workspaces\/.+$/,
render: () => <Workspace />
},
{
path: '/',
render: () => <Start />
}
]}/>
<ContextMenuBoundary>
<Transparency />
<Router routes={[
{
path: /^\/workspaces\/.+\/widgets\/.+$/,
render: () => <WorkspaceWidget />
},
{
path: /^\/workspaces\/.+$/,
render: () => <Workspace />
},
{
path: '/',
render: () => <Start />
}
]}/>
</ContextMenuBoundary>
</SharedContext.Provider>
</LocalContext.Provider>
</SocketContext.Provider>
Expand Down
154 changes: 154 additions & 0 deletions app/components/ContextMenuBoundary/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 (
<Component key={key} {...sanitizeItemSpec(spec)} text={spec?.label} onClick={() => handleClick()}>
{
(spec?.children || [])
.map((child, i) => renderItemSpec(child, `${key}_${i}`))
}
</Component>
)
}

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.open', onRequestContextMenu)
}
setup()

return () => {
if (!bridge) {
return
}
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)
}
}, [])

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

return (
<>
{
contextPos &&
(
<ContextMenu x={contextPos.x} y={contextPos.y} onClose={() => handleClose()}>
{
Array.isArray(spec)
? spec.map((item, i) => renderItemSpec(item, `contextMenu_${i}`))
: renderItemSpec(spec, 'contextMenu')
}
</ContextMenu>
)
}
{children}
</>
)
}
Loading