Tldraw/packages/ui/src/lib/hooks/useActions.tsx

952 wiersze
24 KiB
TypeScript

import {
ANIMATION_MEDIUM_MS,
App,
DEFAULT_BOOKMARK_HEIGHT,
DEFAULT_BOOKMARK_WIDTH,
getEmbedInfo,
openWindow,
TLBookmarkShapeDef,
TLEmbedShapeDef,
TLShapeId,
TLShapePartial,
TLTextShape,
useApp,
} from '@tldraw/editor'
import { approximately, Box2d, TAU, Vec2d } from '@tldraw/primitives'
import { compact } from '@tldraw/utils'
import * as React from 'react'
import { EditLinkDialog } from '../components/EditLinkDialog'
import { EmbedDialog } from '../components/EmbedDialog'
import { TLUiIconType } from '../icon-types'
import { useMenuClipboardEvents } from './useClipboardEvents'
import { useCopyAs } from './useCopyAs'
import { useDialogs } from './useDialogsProvider'
import { TLUiEventSource, useEvents } from './useEventsProvider'
import { useExportAs } from './useExportAs'
import { useInsertMedia } from './useInsertMedia'
import { usePrint } from './usePrint'
import { useToasts } from './useToastsProvider'
import { TLTranslationKey } from './useTranslation/TLTranslationKey'
/** @public */
export interface ActionItem {
icon?: TLUiIconType
id: string
kbd?: string
title?: string
label?: TLTranslationKey
menuLabel?: TLTranslationKey
shortcutsLabel?: TLTranslationKey
contextMenuLabel?: TLTranslationKey
readonlyOk: boolean
checkbox?: boolean
onSelect: (source: TLUiEventSource) => Promise<void> | void
}
/** @public */
export type ActionsContextType = Record<string, ActionItem>
/** @public */
export const ActionsContext = React.createContext<ActionsContextType>({})
/** @public */
export type ActionsProviderProps = {
overrides?: (app: App, actions: ActionsContextType, helpers: undefined) => ActionsContextType
children: any
}
function makeActions(actions: ActionItem[]) {
return Object.fromEntries(actions.map((action) => [action.id, action])) as ActionsContextType
}
/** @public */
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const app = useApp()
// const saveFile = useSaveFile()
// const saveFileAs = useSaveFileAs()
// const newFile = useNewFile()
// const openFile = useOpenFile()
const { addDialog, clearDialogs } = useDialogs()
const { clearToasts } = useToasts()
const insertMedia = useInsertMedia()
const printSelectionOrPages = usePrint()
const { cut, copy } = useMenuClipboardEvents()
const copyAs = useCopyAs()
const exportAs = useExportAs()
const trackEvent = useEvents()
// should this be a useMemo? looks like it doesn't actually deref any reactive values
const actions = React.useMemo<ActionsContextType>(() => {
const actions = makeActions([
{
id: 'edit-link',
label: 'action.edit-link',
icon: 'link',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'edit-link')
app.mark('edit-link')
addDialog({ component: EditLinkDialog })
},
},
{
id: 'insert-embed',
label: 'action.insert-embed',
readonlyOk: false,
kbd: '$i',
onSelect(source) {
trackEvent(source, 'insert-embed')
addDialog({ component: EmbedDialog })
},
},
{
id: 'insert-media',
label: 'action.insert-media',
kbd: '$u',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'insert-media')
insertMedia()
},
},
{
id: 'undo',
label: 'action.undo',
icon: 'undo',
kbd: '$z',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'undo')
app.undo()
},
},
{
id: 'redo',
label: 'action.redo',
icon: 'redo',
kbd: '$!z',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'redo')
app.redo()
},
},
{
id: 'export-as-svg',
label: 'action.export-as-svg',
menuLabel: 'action.export-as-svg.short',
contextMenuLabel: 'action.export-as-svg.short',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'export-as', { format: 'svg' })
exportAs(app.selectedIds, 'svg')
},
},
{
id: 'export-as-png',
label: 'action.export-as-png',
menuLabel: 'action.export-as-png.short',
contextMenuLabel: 'action.export-as-png.short',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'export-as', { format: 'png' })
exportAs(app.selectedIds, 'png')
},
},
{
id: 'export-as-json',
label: 'action.export-as-json',
menuLabel: 'action.export-as-json.short',
contextMenuLabel: 'action.export-as-json.short',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'export-as', { format: 'json' })
exportAs(app.selectedIds, 'json')
},
},
{
id: 'copy-as-svg',
label: 'action.copy-as-svg',
menuLabel: 'action.copy-as-svg.short',
contextMenuLabel: 'action.copy-as-svg.short',
kbd: '$!c',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'svg' })
copyAs(app.selectedIds, 'svg')
},
},
{
id: 'copy-as-png',
label: 'action.copy-as-png',
menuLabel: 'action.copy-as-png.short',
contextMenuLabel: 'action.copy-as-png.short',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'png' })
copyAs(app.selectedIds, 'png')
},
},
{
id: 'copy-as-json',
label: 'action.copy-as-json',
menuLabel: 'action.copy-as-json.short',
contextMenuLabel: 'action.copy-as-json.short',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'json' })
copyAs(app.selectedIds, 'json')
},
},
{
id: 'toggle-auto-size',
label: 'action.toggle-auto-size',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'toggle-auto-size')
app.mark()
app.updateShapes(
app.selectedShapes
.filter((shape) => shape && shape.type === 'text' && shape.props.autoSize === false)
.map((shape: TLTextShape) => {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: 8,
autoSize: true,
},
} as TLTextShape
})
)
},
},
{
id: 'open-embed-link',
label: 'action.open-embed-link',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'open-embed-link')
const ids = app.selectedIds
const warnMsg = 'No embed shapes selected'
if (ids.length !== 1) {
console.error(warnMsg)
return
}
const shape = app.getShapeById(ids[0])
if (!shape || !TLEmbedShapeDef.is(shape)) {
console.error(warnMsg)
return
}
openWindow(shape.props.url, '_blank')
},
},
{
id: 'convert-to-bookmark',
label: 'action.convert-to-bookmark',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'convert-to-bookmark')
const ids = app.selectedIds
const shapes = ids.map((id) => app.getShapeById(id))
const createList: TLShapePartial[] = []
const deleteList: TLShapeId[] = []
for (const shape of shapes) {
if (!shape || !TLEmbedShapeDef.is(shape) || !shape.props.url) continue
const newPos = new Vec2d(shape.x, shape.y)
newPos.rot(-shape.rotation)
newPos.add(
new Vec2d(
shape.props.w / 2 - DEFAULT_BOOKMARK_WIDTH / 2,
shape.props.h / 2 - DEFAULT_BOOKMARK_HEIGHT / 2
)
)
newPos.rot(shape.rotation)
createList.push({
id: app.createShapeId(),
type: 'bookmark',
rotation: shape.rotation,
x: newPos.x,
y: newPos.y,
props: {
url: shape.props.url,
opacity: '1',
},
})
deleteList.push(shape.id)
}
app.mark('convert shapes to bookmark')
app.deleteShapes(deleteList)
app.createShapes(createList)
},
},
{
id: 'convert-to-embed',
label: 'action.convert-to-embed',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'convert-to-embed')
const ids = app.selectedIds
const shapes = compact(ids.map((id) => app.getShapeById(id)))
const createList: TLShapePartial[] = []
const deleteList: TLShapeId[] = []
for (const shape of shapes) {
if (!TLBookmarkShapeDef.is(shape)) continue
const { url } = shape.props
const embedInfo = getEmbedInfo(shape.props.url)
if (!embedInfo) continue
if (!embedInfo.definition) continue
const { width, height, doesResize } = embedInfo.definition
const newPos = new Vec2d(shape.x, shape.y)
newPos.rot(-shape.rotation)
newPos.add(new Vec2d(shape.props.w / 2 - width / 2, shape.props.h / 2 - height / 2))
newPos.rot(shape.rotation)
createList.push({
id: app.createShapeId(),
type: 'embed',
x: newPos.x,
y: newPos.y,
rotation: shape.rotation,
props: {
url: url,
w: width,
h: height,
doesResize,
},
})
deleteList.push(shape.id)
}
app.mark('convert shapes to embed')
app.deleteShapes(deleteList)
app.createShapes(createList)
},
},
{
id: 'duplicate',
kbd: '$d',
label: 'action.duplicate',
icon: 'duplicate',
readonlyOk: false,
onSelect(source) {
if (app.currentToolId !== 'select') return
trackEvent(source, 'duplicate-shapes')
const ids = app.selectedIds
const commonBounds = Box2d.Common(compact(ids.map((id) => app.getPageBoundsById(id))))
const offset = app.canMoveCamera
? {
x: commonBounds.width + 10,
y: 0,
}
: {
x: 16 / app.zoomLevel,
y: 16 / app.zoomLevel,
}
app.mark('duplicate shapes')
app.duplicateShapes(ids, offset)
},
},
{
id: 'ungroup',
label: 'action.ungroup',
kbd: '$!g',
icon: 'ungroup',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'ungroup-shapes')
app.mark('ungroup')
app.ungroupShapes(app.selectedIds)
},
},
{
id: 'group',
label: 'action.group',
kbd: '$g',
icon: 'group',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'group-shapes')
if (app.selectedShapes.length === 1 && app.selectedShapes[0].type === 'group') {
app.mark('ungroup')
app.ungroupShapes(app.selectedIds)
} else {
app.mark('group')
app.groupShapes(app.selectedIds)
}
},
},
{
id: 'align-left',
label: 'action.align-left',
kbd: '?A',
icon: 'align-left',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'left' })
app.mark('align left')
app.alignShapes('left', app.selectedIds)
},
},
{
id: 'align-center-horizontal',
label: 'action.align-center-horizontal',
contextMenuLabel: 'action.align-center-horizontal.short',
kbd: '?H',
icon: 'align-center-horizontal',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'center-horizontal' })
app.mark('align center horizontal')
app.alignShapes('center-horizontal', app.selectedIds)
},
},
{
id: 'align-right',
label: 'action.align-right',
kbd: '?D',
icon: 'align-right',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'right' })
app.mark('align right')
app.alignShapes('right', app.selectedIds)
},
},
{
id: 'align-center-vertical',
label: 'action.align-center-vertical',
contextMenuLabel: 'action.align-center-vertical.short',
kbd: '?V',
icon: 'align-center-vertical',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'center-vertical' })
app.mark('align center vertical')
app.alignShapes('center-vertical', app.selectedIds)
},
},
{
id: 'align-top',
label: 'action.align-top',
icon: 'align-top',
kbd: '?W',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'top' })
app.mark('align top')
app.alignShapes('top', app.selectedIds)
},
},
{
id: 'align-bottom',
label: 'action.align-bottom',
icon: 'align-bottom',
kbd: '?S',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'bottom' })
app.mark('align bottom')
app.alignShapes('bottom', app.selectedIds)
},
},
{
id: 'distribute-horizontal',
label: 'action.distribute-horizontal',
contextMenuLabel: 'action.distribute-horizontal.short',
icon: 'distribute-horizontal',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'distribute-shapes', { operation: 'horizontal' })
app.mark('distribute horizontal')
app.distributeShapes('horizontal', app.selectedIds)
},
},
{
id: 'distribute-vertical',
label: 'action.distribute-vertical',
contextMenuLabel: 'action.distribute-vertical.short',
icon: 'distribute-vertical',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'distribute-shapes', { operation: 'vertical' })
app.mark('distribute vertical')
app.distributeShapes('vertical', app.selectedIds)
},
},
{
id: 'stretch-horizontal',
label: 'action.stretch-horizontal',
contextMenuLabel: 'action.stretch-horizontal.short',
icon: 'stretch-horizontal',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'stretch-shapes', { operation: 'horizontal' })
app.mark('stretch horizontal')
app.stretchShapes('horizontal', app.selectedIds)
},
},
{
id: 'stretch-vertical',
label: 'action.stretch-vertical',
contextMenuLabel: 'action.stretch-vertical.short',
icon: 'stretch-vertical',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'stretch-shapes', { operation: 'vertical' })
app.mark('stretch vertical')
app.stretchShapes('vertical', app.selectedIds)
},
},
{
id: 'flip-horizontal',
label: 'action.flip-horizontal',
contextMenuLabel: 'action.flip-horizontal.short',
kbd: '!h',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'flip-shapes', { operation: 'horizontal' })
app.mark('flip horizontal')
app.flipShapes('horizontal', app.selectedIds)
},
},
{
id: 'flip-vertical',
label: 'action.flip-vertical',
contextMenuLabel: 'action.flip-vertical.short',
kbd: '!v',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'flip-shapes', { operation: 'vertical' })
app.mark('flip vertical')
app.flipShapes('vertical', app.selectedIds)
},
},
{
id: 'pack',
label: 'action.pack',
icon: 'pack',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'pack-shapes')
app.mark('pack')
app.packShapes(app.selectedIds)
},
},
{
id: 'stack-vertical',
label: 'action.stack-vertical',
contextMenuLabel: 'action.stack-vertical.short',
icon: 'stack-vertical',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'stack-shapes', { operation: 'vertical' })
app.mark('stack-vertical')
app.stackShapes('vertical', app.selectedIds)
},
},
{
id: 'stack-horizontal',
label: 'action.stack-horizontal',
contextMenuLabel: 'action.stack-horizontal.short',
icon: 'stack-horizontal',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'stack-shapes', { operation: 'horizontal' })
app.mark('stack-horizontal')
app.stackShapes('horizontal', app.selectedIds)
},
},
{
id: 'bring-to-front',
label: 'action.bring-to-front',
kbd: ']',
icon: 'bring-to-front',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'toFront' })
app.mark('bring to front')
app.bringToFront()
},
},
{
id: 'bring-forward',
label: 'action.bring-forward',
icon: 'bring-forward',
kbd: '?]',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'forward' })
app.mark('bring forward')
app.bringForward()
},
},
{
id: 'send-backward',
label: 'action.send-backward',
icon: 'send-backward',
kbd: '?[',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'backward' })
app.mark('send backward')
app.sendBackward()
},
},
{
id: 'send-to-back',
label: 'action.send-to-back',
icon: 'send-to-back',
kbd: '[',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'toBack' })
app.mark('send to back')
app.sendToBack()
},
},
{
id: 'cut',
label: 'action.cut',
kbd: '$x',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'cut')
app.mark('cut')
cut()
},
},
{
id: 'copy',
label: 'action.copy',
kbd: '$c',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'copy')
copy()
},
},
{
id: 'paste',
label: 'action.paste',
kbd: '$v',
readonlyOk: false,
onSelect() {
// must be inlined with a custom menu item
// the kbd listed here should have no effect
},
},
{
id: 'select-all',
label: 'action.select-all',
kbd: '$a',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'select-all-shapes')
if (app.currentToolId !== 'select') {
app.cancel()
app.setSelectedTool('select')
}
app.mark('select all kbd')
app.selectAll()
},
},
{
id: 'select-none',
label: 'action.select-none',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'select-none-shapes')
app.mark('select none')
app.selectNone()
},
},
{
id: 'delete',
label: 'action.delete',
kbd: '⌫',
icon: 'trash',
readonlyOk: false,
onSelect(source) {
if (app.currentToolId !== 'select') return
trackEvent(source, 'delete-shapes')
app.mark('delete')
app.deleteShapes()
},
},
{
id: 'rotate-cw',
label: 'action.rotate-cw',
icon: 'rotate-cw',
readonlyOk: false,
onSelect(source) {
if (app.selectedIds.length === 0) return
trackEvent(source, 'rotate-cw')
app.mark('rotate-cw')
const offset = app.selectionRotation % (TAU / 2)
const dontUseOffset = approximately(offset, 0) || approximately(offset, TAU / 2)
app.rotateShapesBy(app.selectedIds, TAU / 2 - (dontUseOffset ? 0 : offset))
},
},
{
id: 'rotate-ccw',
label: 'action.rotate-ccw',
icon: 'rotate-ccw',
readonlyOk: false,
onSelect(source) {
if (app.selectedIds.length === 0) return
trackEvent(source, 'rotate-ccw')
app.mark('rotate-ccw')
const offset = app.selectionRotation % (TAU / 2)
const offsetCloseToZero = approximately(offset, 0)
app.rotateShapesBy(app.selectedIds, offsetCloseToZero ? -(TAU / 2) : -offset)
},
},
{
id: 'zoom-in',
label: 'action.zoom-in',
kbd: '$=',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'zoom-in')
app.zoomIn(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
{
id: 'zoom-out',
label: 'action.zoom-out',
kbd: '$-',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'zoom-out')
app.zoomOut(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
{
id: 'zoom-to-100',
label: 'action.zoom-to-100',
icon: 'reset-zoom',
kbd: '!0',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'reset-zoom')
app.resetZoom(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
{
id: 'zoom-to-fit',
label: 'action.zoom-to-fit',
kbd: '!1',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'zoom-to-fit')
app.zoomToFit({ duration: ANIMATION_MEDIUM_MS })
},
},
{
id: 'zoom-to-selection',
label: 'action.zoom-to-selection',
kbd: '!2',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'zoom-to-selection')
app.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
},
},
{
id: 'toggle-snap-mode',
label: 'action.toggle-snap-mode',
menuLabel: 'action.toggle-snap-mode.menu',
readonlyOk: false,
onSelect(source) {
trackEvent(source, 'toggle-snap-mode')
app.setSnapMode(!app.isSnapMode)
},
checkbox: true,
},
{
id: 'toggle-dark-mode',
label: 'action.toggle-dark-mode',
menuLabel: 'action.toggle-dark-mode.menu',
kbd: '$/',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'toggle-dark-mode')
app.setDarkMode(!app.isDarkMode)
},
checkbox: true,
},
{
id: 'toggle-transparent',
label: 'action.toggle-transparent',
menuLabel: 'action.toggle-transparent.menu',
contextMenuLabel: 'action.toggle-transparent.context-menu',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'toggle-transparent')
app.updateInstanceState(
{
exportBackground: !app.instanceState.exportBackground,
},
true
)
},
checkbox: true,
},
{
id: 'toggle-tool-lock',
label: 'action.toggle-tool-lock',
menuLabel: 'action.toggle-tool-lock.menu',
readonlyOk: false,
kbd: 'q',
onSelect(source) {
trackEvent(source, 'toggle-tool-lock')
app.setToolLocked(!app.isToolLocked)
},
checkbox: true,
},
{
id: 'toggle-focus-mode',
label: 'action.toggle-focus-mode',
menuLabel: 'action.toggle-focus-mode.menu',
readonlyOk: true,
kbd: '$.',
checkbox: true,
onSelect(source) {
// this needs to be deferred because it causes the menu
// UI to unmount which puts us in a dodgy state
requestAnimationFrame(() => {
app.batch(() => {
trackEvent(source, 'toggle-focus-mode')
clearDialogs()
clearToasts()
app.setFocusMode(!app.isFocusMode)
})
})
},
},
{
id: 'toggle-grid',
label: 'action.toggle-grid',
menuLabel: 'action.toggle-grid.menu',
readonlyOk: true,
kbd: "$'",
onSelect(source) {
trackEvent(source, 'toggle-grid-mode')
app.setGridMode(!app.isGridMode)
},
checkbox: true,
},
{
id: 'toggle-debug-mode',
label: 'action.toggle-debug-mode',
menuLabel: 'action.toggle-debug-mode.menu',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'toggle-debug-mode')
app.updateInstanceState(
{
isDebugMode: !app.instanceState.isDebugMode,
},
true
)
},
checkbox: true,
},
{
id: 'print',
label: 'action.print',
kbd: '$p',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'print')
printSelectionOrPages()
},
},
{
id: 'exit-pen-mode',
label: 'action.exit-pen-mode',
icon: 'cross-2',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'exit-pen-mode')
app.setPenMode(false)
},
},
{
id: 'stop-following',
label: 'action.stop-following',
icon: 'cross-2',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'stop-following')
app.stopFollowingUser()
},
},
{
id: 'back-to-content',
label: 'action.back-to-content',
icon: 'arrow-left',
readonlyOk: true,
onSelect(source) {
trackEvent(source, 'zoom-to-content')
app.zoomToContent()
},
},
])
if (overrides) {
return overrides(app, actions, undefined)
}
return actions
}, [
trackEvent,
overrides,
app,
addDialog,
insertMedia,
exportAs,
copyAs,
cut,
copy,
clearDialogs,
clearToasts,
printSelectionOrPages,
])
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>
}
/** @public */
export function useActions() {
const ctx = React.useContext(ActionsContext)
if (!ctx) {
throw new Error('useTools must be used within a ToolProvider')
}
return ctx
}
function asActions<T extends Record<string, ActionItem>>(actions: T) {
return actions as Record<keyof typeof actions, ActionItem>
}