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-') ? ( + + ) : ( + {componentPrototype.name} + ); + + 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 ( + + + {menus} + + + ); + }, +); 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';