diff --git a/apps/playground/src/helpers/prototypes.ts b/apps/playground/src/helpers/prototypes.ts index 3a85e6dc..d8bf7c68 100644 --- a/apps/playground/src/helpers/prototypes.ts +++ b/apps/playground/src/helpers/prototypes.ts @@ -35,15 +35,11 @@ const Snippet2ColumnLayout: IComponentPrototype = { package: '@music163/antd', initChildren: ` - - - - - - + + `, - relatedImports: ['Columns', 'Column', 'Box'], + relatedImports: ['Columns', 'Column'], }; const Snippet3ColumnLayout: IComponentPrototype = { @@ -56,17 +52,14 @@ const Snippet3ColumnLayout: IComponentPrototype = { initChildren: ` - - - `, - relatedImports: ['Columns', 'Column', 'Box'], + relatedImports: ['Columns', 'Column'], }; const SnippetButtonGroup: IComponentPrototype = { diff --git a/packages/core/src/models/designer.ts b/packages/core/src/models/designer.ts index e472891b..03f76453 100644 --- a/packages/core/src/models/designer.ts +++ b/packages/core/src/models/designer.ts @@ -20,6 +20,10 @@ interface IDesignerOptions { workspace: IWorkspace; simulator?: SimulatorNameType | ISimulatorType; activeSidebarPanel?: string; + /** + * 默认激活的视图模式 + */ + activeView?: DesignerViewType; } const ISimulatorTypes: Record = { @@ -64,6 +68,16 @@ export class Designer { */ _showSmartWizard = false; + /** + * 是否显示添加组件面板 + */ + _showAddComponentPopover = false; + + /** + * 添加组件面板的位置 + */ + _addComponentPopoverPosition = { clientX: 0, clientY: 0 }; + /** * 是否显示右侧面板 */ @@ -74,11 +88,6 @@ export class Designer { */ _isPreview = false; - /** - * 默认展开的侧边栏 - */ - defaultActiveSidebarPanel?: string; - private readonly workspace: IWorkspace; get simulator(): ISimulatorType { @@ -109,10 +118,22 @@ export class Designer { return this._showRightPanel; } + get showAddComponentPopover() { + return this._showAddComponentPopover; + } + + get addComponentPopoverPosition() { + return this._addComponentPopoverPosition; + } + constructor(options: IDesignerOptions) { this.workspace = options.workspace; - const { simulator, activeSidebarPanel: defaultActiveSidebarPanel } = options; + const { + simulator, + activeSidebarPanel: defaultActiveSidebarPanel, + activeView: defaultActiveView, + } = options; // 默认设计器模式 if (simulator) { @@ -124,6 +145,11 @@ export class Designer { this.setActiveSidebarPanel(defaultActiveSidebarPanel); } + // 默认激活的视图 + if (defaultActiveView) { + this.setActiveView(defaultActiveView); + } + makeObservable(this, { _simulator: observable, _viewport: observable, @@ -131,6 +157,8 @@ export class Designer { _activeSidebarPanel: observable, _showSmartWizard: observable, _showRightPanel: observable, + _showAddComponentPopover: observable, + _addComponentPopoverPosition: observable, _isPreview: observable, simulator: computed, viewport: computed, @@ -139,6 +167,8 @@ export class Designer { isPreview: computed, showRightPanel: computed, showSmartWizard: computed, + showAddComponentPopover: computed, + addComponentPopoverPosition: computed, setSimulator: action, setViewport: action, setActiveView: action, @@ -147,6 +177,7 @@ export class Designer { toggleRightPanel: action, toggleSmartWizard: action, toggleIsPreview: action, + toggleAddComponentPopover: action, }); } @@ -185,6 +216,22 @@ export class Designer { this._showRightPanel = value ?? !this._showRightPanel; } + /** + * 显示添加组件面板 + * @param value 是否显示 + * @param position 坐标 + */ + toggleAddComponentPopover( + value: boolean, + position: { + clientX: number; + clientY: number; + } = this.addComponentPopoverPosition, + ) { + this._showAddComponentPopover = value; + this._addComponentPopoverPosition = position; + } + toggleIsPreview(value: boolean) { this._isPreview = value ?? !this._isPreview; if (value) { diff --git a/packages/designer/src/components/components-popover.tsx b/packages/designer/src/components/components-popover.tsx new file mode 100644 index 00000000..edf5a9f1 --- /dev/null +++ b/packages/designer/src/components/components-popover.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Box } from 'coral-system'; +import { observer, useDesigner, useWorkspace } from '@music163/tango-context'; +import { IconFont, DragPanel } from '@music163/tango-ui'; +import { ComponentsPanel, ComponentsPanelProps } from '../sidebar'; +import { IComponentPrototype } from '@music163/tango-helpers'; + +interface ComponentsPopoverProps { + // 添加组件位置 + type?: 'inner' | 'before' | 'after'; + // 弹出方式 手动触发/DOM 触发 + isControlled?: boolean; + title?: string; + prototype?: IComponentPrototype; + children?: React.ReactNode; +} + +export const ComponentsPopover = observer( + ({ + type = 'inner', + title = '添加组件', + isControlled = false, + prototype, + children, + ...popoverProps + }: ComponentsPopoverProps) => { + const [layout, setLayout] = useState('grid'); + const workspace = useWorkspace(); + const designer = useDesigner(); + + const { addComponentPopoverPosition, showAddComponentPopover } = designer; + const selectedNodeName = workspace.selectSource.selected?.[0]?.codeId ?? '未选中'; + + // 推荐使用的子组件 + const insertedList = useMemo( + () => + Array.isArray(prototype?.childrenName) + ? prototype.childrenName + : [prototype.childrenName].filter(Boolean), + [prototype.childrenName], + ); + + // 推荐使用的代码片段 + const siblingList = useMemo(() => prototype.siblingNames ?? [], [prototype.siblingNames]); + + const tipsTextMap = useMemo( + () => ({ + before: `点击,在 ${selectedNodeName} 的前方添加节点`, + after: `点击,在 ${selectedNodeName} 的后方添加节点`, + inner: `点击,在 ${selectedNodeName} 内部添加节点`, + }), + [selectedNodeName], + ); + + const handleSelect = useCallback( + (name: string) => { + switch (type) { + case 'before': + workspace.insertBeforeSelectedNode(name); + break; + case 'after': + workspace.insertAfterSelectedNode(name); + break; + case 'inner': + workspace.insertToSelectedNode(name); + break; + default: + break; + } + }, + [type, workspace], + ); + + const changeLayout = useCallback(() => { + setLayout(layout === 'grid' ? 'line' : 'grid'); + }, [layout]); + + const menuData = useMemo(() => { + const menuList = JSON.parse(JSON.stringify(workspace.menuData)); + const commonList = menuList['common']; + if (siblingList?.length) { + commonList.unshift({ + title: '代码片段', + items: siblingList, + }); + } + + if (insertedList?.length) { + commonList.unshift({ + title: '推荐使用', + items: insertedList, + }); + } + + return menuList; + }, [insertedList, siblingList, workspace.menuData]); + + const innerTypeProps = + // 手动触发 适用于 点击添加组件 + type === 'inner' && isControlled + ? { + open: showAddComponentPopover, + onOpenChange: (open: boolean) => designer.toggleAddComponentPopover(open), + left: addComponentPopoverPosition.clientX, + top: addComponentPopoverPosition.clientY, + } + : {}; + + return ( + + {layout === 'grid' ? ( + + ) : ( + + )} + + } + footer={tipsTextMap[type]} + width="330px" + body={ + + } + {...popoverProps} + > + {children} + + ); + }, +); diff --git a/packages/designer/src/components/index.ts b/packages/designer/src/components/index.ts index 80ec298b..c598c258 100644 --- a/packages/designer/src/components/index.ts +++ b/packages/designer/src/components/index.ts @@ -2,3 +2,4 @@ export * from './drag-box'; export * from './input-kv'; export * from './variable-tree'; export * from './variable-tree-modal'; +export * from './components-popover'; diff --git a/packages/designer/src/dnd/use-dnd.ts b/packages/designer/src/dnd/use-dnd.ts index 91135d5b..0a7ee674 100644 --- a/packages/designer/src/dnd/use-dnd.ts +++ b/packages/designer/src/dnd/use-dnd.ts @@ -412,6 +412,13 @@ export function useDnd({ // 打开智能向导弹窗 designer.toggleSmartWizard(true); break; + case 'addComponent': + // 打开添加组件面板 + designer.toggleAddComponentPopover(true, { + clientX: (e.detail.meta as any).clientX + 40, + clientY: (e.detail.meta as any).clientY + 110, + }); + break; default: break; } diff --git a/packages/designer/src/simulator/selection.tsx b/packages/designer/src/simulator/selection.tsx index ed68bcac..d2dbfb4f 100644 --- a/packages/designer/src/simulator/selection.tsx +++ b/packages/designer/src/simulator/selection.tsx @@ -1,14 +1,13 @@ -import React, { useMemo, useState } from 'react'; +import React from 'react'; import styled, { css, keyframes } from 'styled-components'; import { Box, Button, Group, HTMLCoralProps } from 'coral-system'; -import { DropdownProps, Tooltip } from 'antd'; +import { Tooltip } from 'antd'; import { HolderOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { ISelectedItemData, isString, noop } from '@music163/tango-helpers'; import { observer, useDesigner, useWorkspace } from '@music163/tango-context'; -import { DragPanel, IconFont } from '@music163/tango-ui'; import { getDragGhostElement } from '../helpers'; import { getWidget } from '../widgets'; -import { ComponentsPanel, ComponentsPanelProps } from '../sidebar'; +import { ComponentsPopover } from '../components'; /** * 选择辅助工具的对齐方式 @@ -111,14 +110,6 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { const prototype = workspace.componentPrototypes.get(data.name); const isPage = prototype?.type === 'page'; - // 推荐使用的子组件 - const insertedList = Array.isArray(prototype?.childrenName) - ? prototype.childrenName - : [prototype.childrenName].filter(Boolean); - - // 推荐使用的代码片段 - const siblingList = prototype.siblingNames ?? []; - let selectionHelpersAlign: SelectionHelperAlignType = 'top-right'; if (data.bounding) { if (data.bounding.left + data.bounding.width + boundingOffset < designer.viewport.width) { @@ -157,30 +148,16 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { style={style} > <> - { - workspace.insertBeforeSelectedNode(name); - }} - > + } css={topAddSiblingBtnStyle} /> - - { - workspace.insertAfterSelectedNode(name); - }} - > + + } css={bottomAddSiblingBtnStyle} /> - + {showActions && ( @@ -220,19 +197,11 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { /> {!isPage && actions} {prototype.hasChildren !== false && ( - { - workspace.insertToSelectedNode(name); - }} - > + } /> - + )} )} @@ -405,83 +374,3 @@ const NameSelector = ({ label, parents = [], onSelect = noop }: NameSelectorProp ); }; - -interface InsertedDropdownProps extends DropdownProps { - title?: string; - footer?: string; - insertedList?: string[]; - siblingList?: string[]; - onSelect?: (name: string) => void; -} - -function InsertedDropdown({ - title = '添加组件', - insertedList = [], - siblingList = [], - onSelect, - footer, - children, - ...props -}: InsertedDropdownProps) { - const workspace = useWorkspace(); - - const [layout, setLayout] = useState('grid'); - - const changeLayout = () => { - setLayout(layout === 'grid' ? 'line' : 'grid'); - }; - - const menuData = useMemo(() => { - const menuList = JSON.parse(JSON.stringify(workspace.menuData)); - const commonList = menuList['common']; - if (siblingList?.length) { - commonList.unshift({ - title: '代码片段', - items: siblingList, - }); - } - - if (insertedList?.length) { - commonList.unshift({ - title: '推荐使用', - items: insertedList, - }); - } - - return menuList; - }, [insertedList, siblingList, workspace.menuData]); - - return ( - - {layout === 'grid' ? ( - - ) : ( - - )} - - } - footer={footer} - width="330px" - placement="bottomCenter" - body={ - - } - {...props} - > - {children} - - ); -} diff --git a/packages/designer/src/workspace-view.tsx b/packages/designer/src/workspace-view.tsx index dc6f5bf0..0218d0f8 100644 --- a/packages/designer/src/workspace-view.tsx +++ b/packages/designer/src/workspace-view.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box } from 'coral-system'; import { observer, useDesigner } from '@music163/tango-context'; import { DesignerViewType } from '@music163/tango-core'; +import { ComponentsPopover } from './components'; export interface WorkspaceViewProps { /** @@ -27,6 +28,8 @@ export const WorkspaceView = observer((props: WorkspaceViewProps) => { position="relative" > {children} + {/* 添加组件弹层 */} + {display === 'block' && } ); }); diff --git a/packages/ui/package.json b/packages/ui/package.json index b0367d20..64f18906 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,7 +45,7 @@ "coral-system": "^1.0.5", "eslint-linter-browserify": "^8.51.0", "react-json-view": "^1.21.3", - "react-monaco-editor-lite": "^1.3.9", + "react-monaco-editor-lite": "^1.3.11", "react-draggable": "^4.4.5" }, "publishConfig": { diff --git a/packages/ui/src/drag-panel.tsx b/packages/ui/src/drag-panel.tsx index b36d4a1f..f8e6d403 100644 --- a/packages/ui/src/drag-panel.tsx +++ b/packages/ui/src/drag-panel.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import { Box, Text, styled } from 'coral-system'; -import { Dropdown, DropdownProps } from 'antd'; +import { Popover, PopoverProps, IconFont } from '@music163/tango-ui'; import Draggable from 'react-draggable'; -import { IconFont } from '@music163/tango-ui'; import { CloseOutlined } from '@ant-design/icons'; const CloseIcon = styled(CloseOutlined)` @@ -17,7 +16,7 @@ const CloseIcon = styled(CloseOutlined)` } `; -interface DragPanelProps extends DropdownProps { +interface DragPanelProps extends Omit { // 标题 title?: React.ReactNode | string; // 内容 @@ -28,6 +27,7 @@ interface DragPanelProps extends DropdownProps { width?: number | string; // 右上角区域 extra?: React.ReactNode | string; + children?: React.ReactNode; } export function DragPanel({ @@ -44,71 +44,68 @@ export function DragPanel({ const footerNode = typeof footer === 'function' ? footer(() => setOpen(false)) : footer; return ( - { - return ( - + onOpenChange={setOpen} + overlay={ + + + {/* 头部区域 */} - {/* 头部区域 */} + + + {title} + + + {extra} + { + setOpen(false); + props?.onOpenChange?.(false); + }} + /> + + + {/* 主体区域 */} + {body} + {/* 底部 */} + {footer && ( - - - {title} - - - {extra} - setOpen(false)} /> - + {footerNode} - {/* 主体区域 */} - {body} - {/* 底部 */} - {footer && ( - - {footerNode} - - )} - - - ); - }} + )} + + + } {...props} > - {React.cloneElement(children as any, { - onClick: (e: Event) => { - e.stopPropagation(); - setOpen(!open); - (children as any).props?.onClick?.(e); - }, - })} - + {children} + ); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fce9ced7..eaa4efbe 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,4 +22,5 @@ export * from './tabs'; export * from './select-action'; export * from './copy-clipboard'; export * from './tag-select'; +export * from './popover'; export * from './drag-panel'; diff --git a/packages/ui/src/popover.tsx b/packages/ui/src/popover.tsx new file mode 100644 index 00000000..e76771d8 --- /dev/null +++ b/packages/ui/src/popover.tsx @@ -0,0 +1,114 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import ReactDOM from 'react-dom'; + +export interface PopoverProps { + open?: boolean; + // 浮层内容 + overlay: React.ReactNode; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; + /** + * 浮层被遮挡时自动调整位置 + */ + autoAdjustOverflow?: boolean; + left?: number; + top?: number; + zIndex?: number; +} + +export const Popover: React.FC = ({ + open, + overlay, + onOpenChange, + autoAdjustOverflow = true, + left: controlledLeft, + top: controlledTop, + children, + zIndex = 9999, +}) => { + const [visible, setVisible] = useState(false); + const [left, setLeft] = useState(0); + const [top, setTop] = useState(0); + const popoverRef = useRef(null); + + // 唤起位置受控 + const isControlledPostion = useMemo( + () => controlledLeft !== undefined || controlledTop !== undefined, + [controlledLeft, controlledTop], + ); + + useEffect(() => { + if (typeof controlledLeft === 'number') { + setLeft(controlledLeft); + } + }, [controlledLeft]); + + useEffect(() => { + if (typeof controlledTop === 'number') { + setTop(controlledTop); + } + }, [controlledTop]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + setLeft(x); + setTop(y + 10); + setVisible(true); + onOpenChange(true); + }; + + useEffect(() => { + setVisible(open); + }, [open]); + + const getAdjustedPosition = () => { + const popoverElement = popoverRef.current; + if (popoverElement) { + const popoverRect = popoverElement.getBoundingClientRect(); + if (popoverRect.right > window.innerWidth) { + setLeft(window.innerWidth - popoverRect.width); + } + if (popoverRect.bottom > window.innerHeight) { + setTop(window.innerHeight - popoverRect.height); + } + } + }; + + useEffect(() => { + if (visible && autoAdjustOverflow) { + getAdjustedPosition(); + } + }, [visible, autoAdjustOverflow]); + + const popoverStyle: React.CSSProperties = { + display: visible ? 'block' : 'none', + position: 'fixed', + left, + top, + zIndex, + }; + + const overlayDom = ( +
+
+ {overlay} +
+
+ ); + + return ( + <> + {!isControlledPostion && + React.cloneElement(children as any, { + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + handleClick(e); + (children as any).props?.onClick?.(e); + }, + })} + {visible ? ReactDOM.createPortal(overlayDom, document.body) : null} + + ); +}; diff --git a/yarn.lock b/yarn.lock index 0da725e0..0feeb4c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15878,10 +15878,10 @@ react-merge-refs@^1.1.0: resolved "https://registry.npmmirror.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz" integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ== -react-monaco-editor-lite@^1.3.9: - version "1.3.9" - resolved "https://registry.npmmirror.com/react-monaco-editor-lite/-/react-monaco-editor-lite-1.3.9.tgz#377a2126f2a26de7e478323c70b13a50f0c5c760" - integrity sha512-pE6CydX/kkisHArbV8GE+TvhukIiT9YYUYBq/cnDL7tfwhX/h1tKQKMJwItnkJvjozaB5uS+oK77GWV874figQ== +react-monaco-editor-lite@^1.3.11: + version "1.3.11" + resolved "https://registry.npmmirror.com/react-monaco-editor-lite/-/react-monaco-editor-lite-1.3.11.tgz#b81b681d23f3ca46f54d4e01f13781bcff6a4a03" + integrity sha512-+9xlIn98Yp2hD8OFjRNpQiBx+wLFzsvvT5EgNTxReFrDhFAPPjivdLUCiex1ktzpkGTdo3fxDFUokckvO7hVvQ== dependencies: monaco-editor "^0.38.0" monaco-editor-textmate "^4.0.0"