diff --git a/packages/core/src/models/designer.ts b/packages/core/src/models/designer.ts
index 57bb7aa..91c3975 100644
--- a/packages/core/src/models/designer.ts
+++ b/packages/core/src/models/designer.ts
@@ -83,6 +83,16 @@ export class Designer {
*/
_addComponentPopoverPosition = { clientX: 0, clientY: 0 };
+ /**
+ * 是否显示右键菜单
+ */
+ _showContextMenu = false;
+
+ /**
+ * 右键菜单在 iframe 上的位置
+ */
+ _contextMenuPosition = { clientX: 0, clientY: 0 };
+
/**
* 是否显示右侧面板
*/
@@ -136,6 +146,14 @@ export class Designer {
return this._addComponentPopoverPosition;
}
+ get showContextMenu() {
+ return this._showContextMenu;
+ }
+
+ get contextMenuPosition() {
+ return this._contextMenuPosition;
+ }
+
get menuData() {
return this._menuData ?? ([] as MenuDataType);
}
@@ -178,6 +196,8 @@ export class Designer {
_showRightPanel: observable,
_showAddComponentPopover: observable,
_addComponentPopoverPosition: observable,
+ _showContextMenu: observable,
+ _contextMenuPosition: observable,
_menuData: observable,
_isPreview: observable,
simulator: computed,
@@ -189,6 +209,8 @@ export class Designer {
showSmartWizard: computed,
showAddComponentPopover: computed,
addComponentPopoverPosition: computed,
+ showContextMenu: computed,
+ contextMenuPosition: computed,
menuData: computed,
setSimulator: action,
setViewport: action,
@@ -199,6 +221,7 @@ export class Designer {
toggleSmartWizard: action,
toggleIsPreview: action,
toggleAddComponentPopover: action,
+ toggleContextMenu: action,
});
}
@@ -264,4 +287,15 @@ export class Designer {
this.workspace.selectSource.clear();
}
}
+
+ toggleContextMenu(
+ value: boolean,
+ position: {
+ clientX: number;
+ clientY: number;
+ } = this.contextMenuPosition,
+ ) {
+ this._showContextMenu = value;
+ this._contextMenuPosition = position;
+ }
}
diff --git a/packages/designer/src/components/context-menu.tsx b/packages/designer/src/components/context-menu.tsx
new file mode 100644
index 0000000..2d4471a
--- /dev/null
+++ b/packages/designer/src/components/context-menu.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import { getWidget } from '../widgets';
+import { Menu, MenuProps } from 'antd';
+import { observer, useWorkspace } from '@music163/tango-context';
+import { ISelectedItemData, isString } from '@music163/tango-helpers';
+import { Box, css } from 'coral-system';
+import { IconFont } from '@music163/tango-ui';
+
+const contextMenuStyle = css`
+ .ant-dropdown-menu {
+ width: 240px;
+ }
+
+ .ant-dropdown-menu-title-content {
+ padding-left: 20px;
+ }
+
+ .ant-dropdown-menu-item-icon + .ant-dropdown-menu-title-content {
+ padding-left: 0;
+ }
+
+ .ant-dropdown-menu-submenu-popup {
+ padding: 0;
+ margin-top: -4px;
+ }
+`;
+
+const ParentNodesMenuItem = ({
+ record,
+ onClick,
+ key,
+}: {
+ record: ISelectedItemData;
+ onClick: () => any;
+ key?: string;
+}) => {
+ const workspace = useWorkspace();
+ const componentPrototype = workspace.componentPrototypes.get(record.name);
+ const icon = componentPrototype?.icon || 'icon-placeholder';
+
+ const iconRender = icon.startsWith('icon-') ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {record.name}
+ {!!record.codeId && ` (${record.codeId})`}
+
+ );
+};
+
+const ParentNodesMenu = observer(() => {
+ const workspace = useWorkspace();
+ const selectSource = workspace.selectSource;
+ const parents = selectSource.first?.parents;
+
+ if (!parents?.length) {
+ return null;
+ }
+
+ return (
+
+ {parents.map((item, index) => (
+ {
+ selectSource.select({
+ ...item,
+ parents: parents.slice(index + 1),
+ });
+ }}
+ />
+ ))}
+
+ );
+});
+
+export interface ContextMenuProps extends MenuProps {
+ /**
+ * 动作列表,默认列出全部
+ */
+ actions?: Array;
+ /**
+ * 是否显示父节点选项
+ */
+ showParents?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+ menuStyle?: React.CSSProperties;
+}
+
+export const ContextMenu = observer(
+ ({
+ showParents,
+ actions: actionsProp,
+ className,
+ style,
+ menuStyle,
+ ...rest
+ }: ContextMenuProps) => {
+ const actions = actionsProp || Object.keys(getWidget('contextMenu'));
+ const menus = actions.map((item) => {
+ if (isString(item)) {
+ const widget = getWidget(['contextMenu', item].join('.'));
+ return widget ? React.createElement(widget) : null;
+ }
+ return item;
+ });
+ if (showParents) {
+ menus.unshift();
+ }
+
+ return (
+
+
+
+ );
+ },
+);
diff --git a/packages/designer/src/components/index.ts b/packages/designer/src/components/index.ts
index c598c25..824226b 100644
--- a/packages/designer/src/components/index.ts
+++ b/packages/designer/src/components/index.ts
@@ -1,3 +1,4 @@
+export * from './context-menu';
export * from './drag-box';
export * from './input-kv';
export * from './variable-tree';
diff --git a/packages/designer/src/context-menu/copy-node.tsx b/packages/designer/src/context-menu/copy-node.tsx
new file mode 100644
index 0000000..2814ac9
--- /dev/null
+++ b/packages/designer/src/context-menu/copy-node.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useWorkspace, observer } from '@music163/tango-context';
+import { ContextAction } from '@music163/tango-ui';
+import { CopyOutlined } from '@ant-design/icons';
+
+export const CopyNodeContextAction = observer(() => {
+ const workspace = useWorkspace();
+ return (
+ }
+ hotkey="Command+C"
+ onClick={() => {
+ workspace.copySelectedNode();
+ }}
+ >
+ 复制节点
+
+ );
+});
diff --git a/packages/designer/src/context-menu/delete-node.tsx b/packages/designer/src/context-menu/delete-node.tsx
new file mode 100644
index 0000000..08fe8c7
--- /dev/null
+++ b/packages/designer/src/context-menu/delete-node.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useWorkspace, observer } from '@music163/tango-context';
+import { ContextAction } from '@music163/tango-ui';
+import { DeleteOutlined } from '@ant-design/icons';
+
+export const DeleteNodeContextAction = observer(() => {
+ const workspace = useWorkspace();
+ return (
+ }
+ hotkey="Backspace"
+ onClick={() => {
+ workspace.removeSelectedNode();
+ }}
+ >
+ 删除节点
+
+ );
+});
diff --git a/packages/designer/src/context-menu/index.tsx b/packages/designer/src/context-menu/index.tsx
new file mode 100644
index 0000000..f103ab5
--- /dev/null
+++ b/packages/designer/src/context-menu/index.tsx
@@ -0,0 +1,4 @@
+export * from './copy-node';
+export * from './delete-node';
+export * from './paste-node';
+export * from './view-source';
diff --git a/packages/designer/src/context-menu/paste-node.tsx b/packages/designer/src/context-menu/paste-node.tsx
new file mode 100644
index 0000000..d4f2346
--- /dev/null
+++ b/packages/designer/src/context-menu/paste-node.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useWorkspace, observer } from '@music163/tango-context';
+import { ContextAction } from '@music163/tango-ui';
+import { SnippetsOutlined } from '@ant-design/icons';
+
+export const PasteNodeContextAction = observer(() => {
+ const workspace = useWorkspace();
+ return (
+ }
+ hotkey="Command+V"
+ onClick={() => {
+ workspace.pasteSelectedNode();
+ }}
+ >
+ 粘贴节点
+
+ );
+});
diff --git a/packages/designer/src/context-menu/view-source.tsx b/packages/designer/src/context-menu/view-source.tsx
new file mode 100644
index 0000000..7ae3e4c
--- /dev/null
+++ b/packages/designer/src/context-menu/view-source.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { useDesigner, observer } from '@music163/tango-context';
+import { ContextAction } from '@music163/tango-ui';
+import { CodeOutlined } from '@ant-design/icons';
+
+export const ViewSourceContextAction = observer(() => {
+ const designer = useDesigner();
+ return (
+ }
+ onClick={() => {
+ designer.setActiveView('code');
+ }}
+ >
+ 查看源码
+
+ );
+});
diff --git a/packages/designer/src/dnd/use-dnd.ts b/packages/designer/src/dnd/use-dnd.ts
index 889c1e2..1da3622 100644
--- a/packages/designer/src/dnd/use-dnd.ts
+++ b/packages/designer/src/dnd/use-dnd.ts
@@ -77,6 +77,9 @@ export function useDnd({
if (designer.isPreview) {
return;
}
+ if (designer.showContextMenu) {
+ designer.toggleContextMenu(false);
+ }
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
selectSource.setStart({
@@ -128,6 +131,9 @@ export function useDnd({
};
const onClick = (e: React.MouseEvent) => {
+ if (designer.showContextMenu) {
+ designer.toggleContextMenu(false);
+ }
const data = sandboxQuery.getDraggableParentsData(e.target as HTMLElement, true);
if (data && data.id) {
selectSource.select(data);
@@ -388,6 +394,56 @@ export function useDnd({
}
};
+ const onContextMenu = (event: React.MouseEvent) => {
+ if (designer.showContextMenu) {
+ designer.toggleContextMenu(false);
+ }
+ // 按下其他按键时,视为用户有特殊操作,此时不展示右键菜单
+ if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ const { clientX, clientY } = event;
+ let target;
+ if (workspace.selectSource.isSelected) {
+ for (const item of workspace.selectSource.selected) {
+ if (
+ // 如果选中的节点是页面根节点(无 parents),则忽略
+ item.parents?.length &&
+ item.bounding &&
+ clientX >= item.bounding.left &&
+ clientX <= item.bounding.left + item.bounding.width &&
+ clientY >= item.bounding.top &&
+ clientY <= item.bounding.top + item.bounding.height
+ ) {
+ // 右键坐标已经在当前选中组件的选区内,直接展示右键菜单
+ target = item;
+ break;
+ }
+ }
+ }
+ // 否则,根据右键的元素选中最接近的组件
+ if (!target) {
+ target = sandboxQuery.getDraggableParentsData(event.target as HTMLElement, true);
+ }
+ if (target && target.id) {
+ if (!target.parents?.length) {
+ // 页面根节点不展示右键菜单操作
+ return;
+ }
+ // 右键时高亮选中当前元素
+ // 以防之前选区有多个元素,即便已经是选中的元素也再选中一遍
+ event.preventDefault();
+ selectSource.select(target);
+ // 在下一周期再展示右键菜单,以让先前的菜单先被销毁
+ requestAnimationFrame(() => {
+ designer.toggleContextMenu(true, {
+ clientX,
+ clientY,
+ });
+ });
+ }
+ };
+
const onTango = (e: CustomEvent) => {
const detail = e.detail || {};
@@ -437,6 +493,7 @@ export function useDnd({
onDragEnd,
onScroll,
onKeyDown,
+ onContextMenu,
onTango,
...selectHandler,
};
diff --git a/packages/designer/src/selection-menu/index.ts b/packages/designer/src/selection-menu/index.ts
index 5ce5388..fd92f30 100644
--- a/packages/designer/src/selection-menu/index.ts
+++ b/packages/designer/src/selection-menu/index.ts
@@ -1,4 +1,5 @@
export * from './copy-node';
export * from './delete-node';
+export * from './more-actions';
export * from './select-parent-node';
export * from './view-source';
diff --git a/packages/designer/src/selection-menu/more-actions.tsx b/packages/designer/src/selection-menu/more-actions.tsx
new file mode 100644
index 0000000..a37ba1d
--- /dev/null
+++ b/packages/designer/src/selection-menu/more-actions.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { observer } from '@music163/tango-context';
+import { SelectAction } from '@music163/tango-ui';
+import { MoreOutlined } from '@ant-design/icons';
+import { Dropdown } from 'antd';
+import { ContextMenu } from '../components';
+
+export const MoreActionsAction = observer(() => {
+ return (
+ }>
+
+
+
+
+ );
+});
diff --git a/packages/designer/src/sidebar/outline-panel/components-tree.tsx b/packages/designer/src/sidebar/outline-panel/components-tree.tsx
index 5454c34..3127fce 100644
--- a/packages/designer/src/sidebar/outline-panel/components-tree.tsx
+++ b/packages/designer/src/sidebar/outline-panel/components-tree.tsx
@@ -8,6 +8,7 @@ import { DropMethod, ITangoViewNodeData } from '@music163/tango-core';
import { noop, parseDndId } from '@music163/tango-helpers';
import { useSandboxQuery } from '../../context';
import { buildQueryBySlotId } from '../../helpers';
+import { ContextMenu } from '../../components';
export interface ComponentsTreeProps {
/**
@@ -143,6 +144,18 @@ export const ComponentsTree: React.FC = observer(
const file = workspace.activeViewModule;
const nodesTree = (file?.nodesTree ?? []) as ITangoViewNodeData[];
const [expandedKeys, setExpandedKeys] = useState(getNodeKeys(nodesTree));
+ const [contextMenuOpen, setContextMenuOpen] = useState(false);
+
+ const handleSelect = (keys: React.Key[]) => {
+ const slotKey = keys?.[0] as string;
+ const data = sandboxQuery.getDraggableParentsData(buildQueryBySlotId(slotKey), true);
+ if (data && data.id) {
+ workspace.selectSource.select(data);
+ }
+ // export selected
+ onSelect(slotKey);
+ setSelectedKeys(keys);
+ };
useEffect(() => {
setSelectedKeys(workspace.selectSource.selected.map((item) => item.id));
@@ -164,64 +177,83 @@ export const ComponentsTree: React.FC = observer(
return (
- {
- const slotKey = keys?.[0] as string;
- const data = sandboxQuery.getDraggableParentsData(buildQueryBySlotId(slotKey), true);
- if (data && data.id) {
- workspace.selectSource.select(data);
- }
- // export selected
- onSelect(slotKey);
- setSelectedKeys(keys);
- }}
- blockNode
- expandedKeys={expandedKeys}
- onExpand={(keys) => setExpandedKeys(keys as string[])}
- draggable
- onDragStart={(data) => {
- const prototype = workspace.componentPrototypes.get(data.node.component);
- if (!prototype) {
- return;
- }
- const { canDrag } = prototype.rules || {};
- if (canDrag && !canDrag()) {
- return;
+ {
+ if (!val) {
+ setContextMenuOpen(val);
}
- workspace.dragSource.set({
- id: data.node.id,
- name: data.node.component,
- });
}}
- onDrop={(data) => {
- const dropKey = data.node.key as string;
- let method;
- if (data.dropToGap) {
- // 插入节点的后面
- method = DropMethod.InsertAfter;
- } else {
- // 作为第一个子节点
- method = DropMethod.InsertFirstChild;
- }
- workspace.dragSource.dropTarget.set(
- {
- id: dropKey,
- },
- method,
- );
- workspace.dropNode();
- }}
- titleRender={(node) => (
-
- )}
- />
+ overlay={ setContextMenuOpen(false)} showParents={false} />}
+ >
+ setExpandedKeys(keys as string[])}
+ draggable
+ onDragStart={(data) => {
+ const prototype = workspace.componentPrototypes.get(data.node.component);
+ if (!prototype) {
+ return;
+ }
+ const { canDrag } = prototype.rules || {};
+ if (canDrag && !canDrag()) {
+ return;
+ }
+ workspace.dragSource.set({
+ id: data.node.id,
+ name: data.node.component,
+ });
+ }}
+ onDrop={(data) => {
+ const dropKey = data.node.key as string;
+ let method;
+ if (data.dropToGap) {
+ // 插入节点的后面
+ method = DropMethod.InsertAfter;
+ } else {
+ // 作为第一个子节点
+ method = DropMethod.InsertFirstChild;
+ }
+ workspace.dragSource.dropTarget.set(
+ {
+ id: dropKey,
+ },
+ method,
+ );
+ workspace.dropNode();
+ }}
+ onRightClick={({ event, node }) => {
+ // 按下其他按键时,视为用户有特殊操作,此时不展示右键菜单
+ if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
+ setContextMenuOpen(false);
+ return;
+ }
+ // 顶层的 Page 组件不展示右键菜单
+ if (node.component === 'Page') {
+ setContextMenuOpen(false);
+ return;
+ }
+ event.preventDefault();
+ // 由于部分操作是基于 selectSource 实现的,因此右键时默认选中当前项
+ handleSelect([node.key]);
+ setContextMenuOpen(true);
+ }}
+ titleRender={(node) => (
+
+ )}
+ />
+
);
},
diff --git a/packages/designer/src/simulator/selection.tsx b/packages/designer/src/simulator/selection.tsx
index 92ede72..8739fdf 100644
--- a/packages/designer/src/simulator/selection.tsx
+++ b/packages/designer/src/simulator/selection.tsx
@@ -25,7 +25,13 @@ export interface SelectionToolsProps {
export const SelectionTools = observer(
({
- actions: actionsProp = ['selectParentNode', 'viewSource', 'copyNode', 'deleteNode'],
+ actions: actionsProp = [
+ 'selectParentNode',
+ 'viewSource',
+ 'copyNode',
+ 'deleteNode',
+ 'moreActions',
+ ],
}: SelectionToolsProps) => {
const workspace = useWorkspace();
const selectSource = workspace.selectSource;
diff --git a/packages/designer/src/simulator/viewport.tsx b/packages/designer/src/simulator/viewport.tsx
index dbe0be9..9dba260 100644
--- a/packages/designer/src/simulator/viewport.tsx
+++ b/packages/designer/src/simulator/viewport.tsx
@@ -1,20 +1,61 @@
import React, { useEffect, useRef } from 'react';
import cx from 'classnames';
import { Box, HTMLCoralProps } from 'coral-system';
-import { useDesigner, useWorkspace } from '@music163/tango-context';
+import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { Ghost } from './ghost';
import { useSandboxQuery } from '../context';
import { SelectionTools, SelectionToolsProps } from './selection';
import { InsertionPrompt } from './insertion';
import { DraggingMask } from './mask';
+import { Dropdown, DropdownProps } from 'antd';
+import { ContextMenu } from '../components';
export interface ViewportProps extends HTMLCoralProps<'div'> {
selectionTools?: SelectionToolsProps['actions'];
}
+const ViewportContextMenu = observer((props: DropdownProps) => {
+ const designer = useDesigner();
+
+ // FIXME: 考虑优化定位实现?
+ // 由于 iframe 内的 contextmenu 事件无法被外层感知到,所以无法自动展示与定位
+ // 而手动指定 align 会干扰 contextmenu 溢出时的位置处理,会定位到完全相反的位置
+ // 因此,给 dropdown 单独提供一个定位容器,将其定位至 iframe 内触发点击的位置
+ // 并在每次 contextmenu 隐藏时销毁 dropdown,让 dropdown 能重新获取新的位置
+ return (
+ designer.showContextMenu && (
+ {
+ if (!val) {
+ designer.toggleContextMenu(val);
+ }
+ }}
+ overlay={ designer.toggleContextMenu(false)} showParents />}
+ {...props}
+ >
+
+
+ )
+ );
+});
+
export function Viewport({ selectionTools, children, className, ...rest }: ViewportProps) {
return (
+
{children}
diff --git a/packages/designer/src/widgets.ts b/packages/designer/src/widgets.ts
index 7bd545b..109ce45 100644
--- a/packages/designer/src/widgets.ts
+++ b/packages/designer/src/widgets.ts
@@ -18,14 +18,21 @@ import {
DeleteNodeAction,
ViewSourceAction,
SelectParentNodeAction,
+ MoreActionsAction,
} from './selection-menu';
+import {
+ CopyNodeContextAction,
+ DeleteNodeContextAction,
+ PasteNodeContextAction,
+ ViewSourceContextAction,
+} from './context-menu';
const widgets = {};
export function registerWidget(key: string, component: React.ComponentType
) {
- if (!/^(toolbar|sidebar|selectionMenu)\.[a-zA-z]+$/.test(key)) {
+ if (!/^(toolbar|sidebar|selectionMenu|contextMenu)\.[a-zA-z]+$/.test(key)) {
throw new Error(
- `Invalid widget key(${key}), should start with toolbar, sidebar, or selection-menu`,
+ `Invalid widget key(${key}), should start with toolbar, sidebar, contextMenu or selectionMenu`,
);
}
setValue(widgets, key, component);
@@ -55,3 +62,9 @@ registerWidget('selectionMenu.copyNode', CopyNodeAction);
registerWidget('selectionMenu.deleteNode', DeleteNodeAction);
registerWidget('selectionMenu.selectParentNode', SelectParentNodeAction);
registerWidget('selectionMenu.viewSource', ViewSourceAction);
+registerWidget('selectionMenu.moreActions', MoreActionsAction);
+
+registerWidget('contextMenu.viewSource', ViewSourceContextAction);
+registerWidget('contextMenu.copyNode', CopyNodeContextAction);
+registerWidget('contextMenu.pasteNode', PasteNodeContextAction);
+registerWidget('contextMenu.deleteNode', DeleteNodeContextAction);
diff --git a/packages/helpers/src/helpers/assert.ts b/packages/helpers/src/helpers/assert.ts
index cecc538..8f76b70 100644
--- a/packages/helpers/src/helpers/assert.ts
+++ b/packages/helpers/src/helpers/assert.ts
@@ -68,3 +68,10 @@ export function isServiceVariablePath(key: string) {
export function isInTangoDesignMode() {
return !!(window as any).__TANGO_DESIGNER__;
}
+
+/**
+ * 是否是 macOS 或 iOS like 设备
+ */
+export function isApplePlatform() {
+ return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
+}
diff --git a/packages/ui/src/context-action.tsx b/packages/ui/src/context-action.tsx
new file mode 100644
index 0000000..43f3ef7
--- /dev/null
+++ b/packages/ui/src/context-action.tsx
@@ -0,0 +1,86 @@
+import React, { useMemo } from 'react';
+import { Menu, Space } from 'antd';
+import { Box, css } from 'coral-system';
+import { isApplePlatform } from '@music163/tango-helpers';
+
+const contextActionStyle = css`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+
+ .ContextActionContent {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .ContextActionExtra {
+ flex: none;
+ color: var(--tango-colors-gray-40);
+ }
+`;
+
+interface ContextActionProps {
+ icon?: React.ReactNode;
+ children?: React.ReactNode;
+ hotkey?: string;
+ extra?: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+ disabled?: boolean;
+ key?: string;
+}
+
+export function ContextAction({
+ icon,
+ children,
+ hotkey,
+ extra,
+ disabled,
+ onClick,
+ className,
+ key,
+}: ContextActionProps) {
+ const normalizedHotKey = useMemo(() => {
+ if (!hotkey) {
+ return null;
+ }
+ const keyMap = isApplePlatform()
+ ? {
+ command: '⌘',
+ meta: '⌘',
+ ctrl: '^',
+ control: '^',
+ alt: '⌥',
+ option: '⌥',
+ shift: '⇧',
+ '+': '',
+ }
+ : {
+ command: 'Ctrl',
+ meta: 'Win',
+ ctrl: 'Ctrl',
+ control: 'Ctrl',
+ alt: 'Alt',
+ option: 'Alt',
+ shift: 'Shift',
+ };
+ const regexp = new RegExp(Object.keys(keyMap).join('|').replace(/\\+/, '\\+'), 'ig');
+ return hotkey.replace(regexp, (match) => keyMap[match.toLowerCase()]);
+ }, [hotkey]);
+
+ return (
+
+
+ {children}
+
+
+ {normalizedHotKey}
+ {extra}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index eaa4efb..53c0200 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -24,3 +24,4 @@ export * from './copy-clipboard';
export * from './tag-select';
export * from './popover';
export * from './drag-panel';
+export * from './context-action';