From f17ccbb7f645f8047ecd96d9f3f2185048a3b726 Mon Sep 17 00:00:00 2001 From: BoBoooooo <17746714@qq.com> Date: Mon, 20 May 2024 16:13:34 +0800 Subject: [PATCH] fix: support add components with popover (#155) * feat: add drag panel & popover component * feat: update web ide version to 1.3.11 * feat: add components-popover * fix: update sidebar default width * feat: add global components popover * fix: _menuData default value * feat: add addComponent event * revert: remove isCollapsed prop * fix: update local icon font url --- apps/playground/src/helpers/mock-files.ts | 3 +- apps/playground/src/helpers/prototypes.ts | 22 +- apps/playground/src/pages/index.tsx | 59 ++--- apps/playground/src/pages/mail.tsx | 3 +- apps/storybook/src/setting-form.stories.tsx | 2 +- packages/core/src/factory.ts | 7 + packages/core/src/models/designer.ts | 64 +++++- .../src/components/components-popover.tsx | 148 ++++++++++++ packages/designer/src/components/index.ts | 1 + packages/designer/src/dnd/use-dnd.ts | 7 + .../designer/src/sidebar/components-panel.tsx | 217 +++++++++++++++--- packages/designer/src/sidebar/sidebar.tsx | 2 +- packages/designer/src/simulator/selection.tsx | 193 ++-------------- packages/designer/src/workspace-view.tsx | 3 + packages/helpers/src/types/prototype.ts | 17 ++ packages/ui/package.json | 3 +- packages/ui/src/collapse-panel.tsx | 8 +- packages/ui/src/drag-panel.tsx | 122 ++++++++++ packages/ui/src/index.ts | 2 + packages/ui/src/popover.tsx | 166 ++++++++++++++ yarn.lock | 41 +--- 21 files changed, 798 insertions(+), 292 deletions(-) create mode 100644 packages/designer/src/components/components-popover.tsx create mode 100644 packages/ui/src/drag-panel.tsx create mode 100644 packages/ui/src/popover.tsx diff --git a/apps/playground/src/helpers/mock-files.ts b/apps/playground/src/helpers/mock-files.ts index 3a6582c6..e11bc156 100644 --- a/apps/playground/src/helpers/mock-files.ts +++ b/apps/playground/src/helpers/mock-files.ts @@ -165,6 +165,7 @@ class App extends React.Component { render() { return ( +
your input: copy input: @@ -191,7 +192,7 @@ class App extends React.Component {

- hello world + hello world

- - - + + + + + + `, relatedImports: ['Columns', 'Column'], @@ -76,13 +80,9 @@ const SnippetButtonGroup: IComponentPrototype = { // hack some prototypes basePrototypes['Section'].siblingNames = [ 'SnippetButtonGroup', - 'Section', - 'Section', - 'Section', - 'Section', - 'Section', - 'Section', - 'Section', + 'Snippet2ColumnLayout', + 'Snippet3ColumnLayout', + 'SnippetSuccessResult', ]; export const nativeDomPrototypes = () => { diff --git a/apps/playground/src/pages/index.tsx b/apps/playground/src/pages/index.tsx index fc871c08..179c1ad3 100644 --- a/apps/playground/src/pages/index.tsx +++ b/apps/playground/src/pages/index.tsx @@ -28,35 +28,6 @@ import { import { Action } from '@music163/tango-ui'; import { useState } from 'react'; -// 1. 实例化工作区 -const workspace = new Workspace({ - entry: '/src/index.js', - files: sampleFiles, - prototypes, -}); - -// inject workspace to window for debug -(window as any).__workspace__ = workspace; - -// 2. 引擎初始化 -const engine = createEngine({ - workspace, - defaultActiveView: 'design', // dual code design -}); - -// @ts-ignore -window.__workspace__ = workspace; - -// 3. 沙箱初始化 -const sandboxQuery = new DndQuery({ - context: 'iframe', -}); - -// 4. 图标库初始化(物料面板和组件树使用了 iconfont 里的图标) -createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/c/font_2891794_6d4hj5u0bjx.js', -}); - const menuData = { common: [ { @@ -91,6 +62,36 @@ const menuData = { ], }; +// 1. 实例化工作区 +const workspace = new Workspace({ + entry: '/src/index.js', + files: sampleFiles, + prototypes, +}); + +// inject workspace to window for debug +(window as any).__workspace__ = workspace; + +// 2. 引擎初始化 +const engine = createEngine({ + workspace, + menuData, + defaultActiveView: 'design', // dual code design +}); + +// @ts-ignore +window.__workspace__ = workspace; + +// 3. 沙箱初始化 +const sandboxQuery = new DndQuery({ + context: 'iframe', +}); + +// 4. 图标库初始化(物料面板和组件树使用了 iconfont 里的图标) +createFromIconfontCN({ + scriptUrl: '//at.alicdn.com/t/c/font_2891794_cxbtmzehxyi.js', +}); + /** * 5. 平台初始化,访问 https://local.netease.com:6006/ */ diff --git a/apps/playground/src/pages/mail.tsx b/apps/playground/src/pages/mail.tsx index 9cbd9b4e..6c4dc185 100644 --- a/apps/playground/src/pages/mail.tsx +++ b/apps/playground/src/pages/mail.tsx @@ -45,7 +45,7 @@ const sandboxQuery = new DndQuery({ // 4. 图标库初始化(物料面板和组件树使用了 iconfont 里的图标) createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/c/font_2891794_cou9i7556tl.js', + scriptUrl: '//at.alicdn.com/t/c/font_2891794_cxbtmzehxyi.js', }); /** @@ -112,6 +112,7 @@ export default function App() { if (sandboxWindow.TangoMail) { if (sandboxWindow.TangoMail.menuData) { setMenuData(sandboxWindow.TangoMail.menuData); + engine.designer.setMenuData(sandboxWindow.TangoMail.menuData); } if (sandboxWindow.TangoMail.prototypes) { workspace.setComponentPrototypes(sandboxWindow.TangoMail.prototypes); diff --git a/apps/storybook/src/setting-form.stories.tsx b/apps/storybook/src/setting-form.stories.tsx index b539d6cd..da4e1186 100644 --- a/apps/storybook/src/setting-form.stories.tsx +++ b/apps/storybook/src/setting-form.stories.tsx @@ -14,7 +14,7 @@ const BLACK_LIST = ['codeSetter', 'eventSetter', 'modelSetter', 'routerSetter']; BUILT_IN_SETTERS.filter((setter) => !BLACK_LIST.includes(setter.name)).forEach(register); createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/c/font_2891794_cou9i7556tl.js', + scriptUrl: '//at.alicdn.com/t/c/font_2891794_cxbtmzehxyi.js', }); export default { diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index 4eb8456d..58b971c6 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -1,3 +1,4 @@ +import { MenuDataType } from '@music163/tango-helpers'; import { Designer, DesignerViewType, Engine, SimulatorNameType } from './models'; import { IWorkspace } from './models/interfaces'; @@ -6,6 +7,10 @@ interface ICreateEngineOptions { * 自定义工作区 */ workspace?: IWorkspace; + /** + * 菜单信息 + */ + menuData?: MenuDataType; /** * 默认的模拟器模式 */ @@ -30,6 +35,7 @@ export function createEngine({ defaultActiveView = 'design', defaultSimulatorMode = 'desktop', defaultActiveSidebarPanel = '', + menuData, }: ICreateEngineOptions) { const engine = new Engine({ workspace, @@ -38,6 +44,7 @@ export function createEngine({ simulator: defaultSimulatorMode, activeView: defaultActiveView, activeSidebarPanel: defaultActiveSidebarPanel, + menuData, }), }); diff --git a/packages/core/src/models/designer.ts b/packages/core/src/models/designer.ts index 7dcc3989..57bb7aa5 100644 --- a/packages/core/src/models/designer.ts +++ b/packages/core/src/models/designer.ts @@ -1,5 +1,6 @@ import { action, computed, makeObservable, observable, toJS } from 'mobx'; import { IWorkspace } from './interfaces'; +import { MenuDataType } from '@music163/tango-helpers'; export type SimulatorNameType = 'desktop' | 'phone'; @@ -19,6 +20,10 @@ interface IViewportBounding { interface IDesignerOptions { workspace: IWorkspace; simulator?: SimulatorNameType | ISimulatorType; + /** + * 菜单配置 + */ + menuData: MenuDataType; activeSidebarPanel?: string; /** * 默认激活的视图模式 @@ -68,6 +73,16 @@ export class Designer { */ _showSmartWizard = false; + /** + * 是否显示添加组件面板 + */ + _showAddComponentPopover = false; + + /** + * 添加组件面板的位置 + */ + _addComponentPopoverPosition = { clientX: 0, clientY: 0 }; + /** * 是否显示右侧面板 */ @@ -79,9 +94,9 @@ export class Designer { _isPreview = false; /** - * 默认展开的侧边栏 + * 菜单列表 */ - defaultActiveSidebarPanel?: string; + _menuData?: MenuDataType = null; private readonly workspace: IWorkspace; @@ -113,15 +128,32 @@ export class Designer { return this._showRightPanel; } + get showAddComponentPopover() { + return this._showAddComponentPopover; + } + + get addComponentPopoverPosition() { + return this._addComponentPopoverPosition; + } + + get menuData() { + return this._menuData ?? ([] as MenuDataType); + } + constructor(options: IDesignerOptions) { this.workspace = options.workspace; const { simulator, + menuData, activeSidebarPanel: defaultActiveSidebarPanel, activeView: defaultActiveView, } = options; + if (menuData) { + this.setMenuData(menuData); + } + // 默认设计器模式 if (simulator) { this.setSimulator(simulator); @@ -144,6 +176,9 @@ export class Designer { _activeSidebarPanel: observable, _showSmartWizard: observable, _showRightPanel: observable, + _showAddComponentPopover: observable, + _addComponentPopoverPosition: observable, + _menuData: observable, _isPreview: observable, simulator: computed, viewport: computed, @@ -152,6 +187,9 @@ export class Designer { isPreview: computed, showRightPanel: computed, showSmartWizard: computed, + showAddComponentPopover: computed, + addComponentPopoverPosition: computed, + menuData: computed, setSimulator: action, setViewport: action, setActiveView: action, @@ -160,6 +198,7 @@ export class Designer { toggleRightPanel: action, toggleSmartWizard: action, toggleIsPreview: action, + toggleAddComponentPopover: action, }); } @@ -186,6 +225,11 @@ export class Designer { this._activeSidebarPanel = ''; } } + + setMenuData(menuData: MenuDataType) { + this._menuData = menuData; + } + closeSidebarPanel() { this._activeSidebarPanel = ''; } @@ -198,6 +242,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..2d6d3b06 --- /dev/null +++ b/packages/designer/src/components/components-popover.tsx @@ -0,0 +1,148 @@ +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, + children, + ...popoverProps + }: ComponentsPopoverProps) => { + const [layout, setLayout] = useState('grid'); + const workspace = useWorkspace(); + const designer = useDesigner(); + + const { addComponentPopoverPosition, showAddComponentPopover } = designer; + const selectedNode = workspace.selectSource.selected?.[0]; + const selectedNodeId = selectedNode?.codeId ?? '未选中'; + const prototype = + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + workspace.componentPrototypes.get(selectedNode?.name) ?? ({} as IComponentPrototype); + + // 推荐使用的子组件 + 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: `点击,在 ${selectedNodeId} 的前方添加节点`, + after: `点击,在 ${selectedNodeId} 的后方添加节点`, + inner: `点击,在 ${selectedNodeId} 内部添加节点`, + }), + [selectedNodeId], + ); + + 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(designer.menuData)); + + const commonList = menuList['common'] ?? []; + if (commonList?.length && siblingList?.length) { + commonList.unshift({ + title: '代码片段', + items: siblingList, + }); + } + + if (commonList?.length && insertedList?.length) { + commonList.unshift({ + title: '推荐使用', + items: insertedList, + }); + } + + return menuList; + }, [insertedList, siblingList, designer.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" + maskClosable + 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/sidebar/components-panel.tsx b/packages/designer/src/sidebar/components-panel.tsx index 0f8752eb..2ed2cf63 100644 --- a/packages/designer/src/sidebar/components-panel.tsx +++ b/packages/designer/src/sidebar/components-panel.tsx @@ -1,21 +1,29 @@ import React, { useMemo, useState } from 'react'; import { Box, Grid, Text } from 'coral-system'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { IComponentPrototype, + MenuDataType, + MenuValueType, + createContext, logger, - PartialRecord, upperCamelCase, } from '@music163/tango-helpers'; import { CollapsePanel, IconFont, Search, Tabs } from '@music163/tango-ui'; import { observer, useWorkspace } from '@music163/tango-context'; import { QuestionCircleOutlined } from '@ant-design/icons'; -import { Button, Empty, Spin, Popover } from 'antd'; +import { Button, Empty, Spin, Popover, TabsProps } from 'antd'; import { getDragGhostElement } from '../helpers'; -type MenuKeyType = 'common' | 'atom' | 'snippet' | 'bizComp' | 'localComp'; -type MenuValueType = Array<{ title: string; items: string[] }>; -export type MenuDataType = PartialRecord; +type IComponentsPanelContext = { + isScope: boolean; + onItemSelect: (name: string) => void; + layout: 'grid' | 'line'; +}; + +const [ComponentsPanelProvider, usePanelContext] = createContext({ + name: 'ComponentsPanelContext', +}); export interface ComponentsPanelProps { /** @@ -36,6 +44,26 @@ export interface ComponentsPanelProps { * @returns */ getBizCompName?: (name: string) => string; + /** + * 是否局部模式 (快捷添加组件面板中使用) + */ + isScope?: boolean; + /** + * 组件选中回调 + */ + onItemSelect?: (name: string) => void; + /** + * 自定义样式 + */ + style?: React.CSSProperties; + /** + * tabProps + */ + tabProps?: TabsProps; + /** + * 布局模式,默认网格布局 + */ + layout?: 'grid' | 'line'; } const localeMap = { @@ -72,10 +100,15 @@ export function useFlatMenuData(menuData: T) { export const ComponentsPanel = observer( ({ + isScope = false, menuData = emptyMenuData, showBizComps = true, getBizCompName = upperCamelCase, loading = false, + style, + tabProps, + onItemSelect, + layout = 'grid', }: ComponentsPanelProps) => { const [keyword, setKeyword] = useState(''); const allList = useFlatMenuData(menuData); @@ -109,22 +142,44 @@ export const ComponentsPanel = observer( children: , }); } + const contentNode = tabs.length === 1 ? ( tabs[0].children ) : ( - + ); return ( - - - + + + + + + + {!keyword ? contentNode : } + - - {!keyword ? contentNode : } - - + ); }, ); @@ -146,6 +201,9 @@ interface MaterialListProps { function MaterialList({ data, filterKeyword, type = 'common' }: MaterialListProps) { const workspace = useWorkspace(); + const { layout } = usePanelContext(); + const isGrid = layout === 'grid'; + return ( {data.map((cate) => { @@ -166,15 +224,21 @@ function MaterialList({ data, filterKeyword, type = 'common' }: MaterialListProp title={cate.title} borderBottom="solid" borderColor="line.normal" + showBottomBorder={false} + bodyProps={{ + padding: isGrid ? '4px 12px 12px' : '0', + }} > {!items.length && ( )} {items.map((item) => { const prototype = workspace.componentPrototypes.get(item); @@ -202,9 +266,8 @@ const StyledCommonGridItem = styled.div` display: flex; flex-direction: column; align-items: center; - justify-content: center; - padding: 8px; - cursor: move; + justify-content: start; + cursor: ${(props) => (props.draggable ? 'grab' : 'pointer')}; text-align: center; color: var(--tango-colors-text-body); background-color: #fff; @@ -213,7 +276,19 @@ const StyledCommonGridItem = styled.div` white-space: nowrap; .material-icon { - font-size: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + background: #f9f9f9; + border-radius: 4px; + border: 1px solid #ebebeb; + width: 100%; + height: 52px; + position: relative; + transition: 0.15s ease-in-out; + transition-property: transform; + will-change: transform; } .info { @@ -225,19 +300,13 @@ const StyledCommonGridItem = styled.div` .anticon-question-circle { display: none; + font-size: 13px; position: absolute; - top: 8px; - right: 8px; - } - - img { - height: 40px; - width: 40px; + top: 4px; + right: 4px; } &:hover { - box-shadow: 0 0 10px rgb(0 0 0 / 10%); - > span { color: var(--tango-colors-brand); } @@ -245,11 +314,26 @@ const StyledCommonGridItem = styled.div` .anticon-question-circle { display: inline-block; } + .material-icon { + border-color: #c7c7c7; + } + } +`; + +const GridLineItemStyle = css` + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--tango-colors-fill2); } `; function MaterialGrid({ data }: MaterialProps) { const workspace = useWorkspace(); + const { isScope, onItemSelect, layout } = usePanelContext(); + + const isLine = layout === 'line'; const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.effectAllowed = 'move'; @@ -266,28 +350,91 @@ function MaterialGrid({ data }: MaterialProps) { workspace.dragSource.clear(); }; + const handleSelect = () => { + onItemSelect?.(data.name); + }; + const icon = data.icon || 'icon-placeholder'; + if (isLine) { + return ( + + + {icon.startsWith('icon-') ? ( + + ) : ( + {data.name} + )} + + + + {data.title} + {data.docs ? ( + } + > + + + ) : null} + + + {data.help ?? data.title} + + + + ); + } return ( {icon.startsWith('icon-') ? ( ) : ( - {data.name} + {data.name} )} - - {data.title} + + {data.title ?? data.name} - + {data.name} {data.docs || data.help ? ( - }> + } + > ) : null} @@ -295,7 +442,7 @@ function MaterialGrid({ data }: MaterialProps) { ); } -function CommonMaterialInfoBox({ help, docs }: IComponentPrototype) { +function CommonMaterialInfoBox({ help, docs }: Pick) { return ( {!!help && {help}} diff --git a/packages/designer/src/sidebar/sidebar.tsx b/packages/designer/src/sidebar/sidebar.tsx index c8c8e2df..89e7d7d7 100644 --- a/packages/designer/src/sidebar/sidebar.tsx +++ b/packages/designer/src/sidebar/sidebar.tsx @@ -74,7 +74,7 @@ export interface SidebarPanelItemProps widgetProps?: object; } -function BaseSidebarPanel({ panelWidth: defaultPanelWidth = 280, footer, children }: SidebarProps) { +function BaseSidebarPanel({ panelWidth: defaultPanelWidth = 266, footer, children }: SidebarProps) { const designer = useDesigner(); const items = useMemo(() => { diff --git a/packages/designer/src/simulator/selection.tsx b/packages/designer/src/simulator/selection.tsx index a9146c19..7dbd0ecf 100644 --- a/packages/designer/src/simulator/selection.tsx +++ b/packages/designer/src/simulator/selection.tsx @@ -1,13 +1,13 @@ import React from 'react'; import styled, { css, keyframes } from 'styled-components'; import { Box, Button, Group, HTMLCoralProps } from 'coral-system'; -import { Dropdown, 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 { IconFont } from '@music163/tango-ui'; import { getDragGhostElement } from '../helpers'; import { getWidget } from '../widgets'; +import { ComponentsPopover } from '../components'; /** * 选择辅助工具的对齐方式 @@ -79,13 +79,6 @@ const bottomAddSiblingBtnStyle = css` pointer-events: auto; `; -interface IInsertedData { - name: string; - label: string; - icon: string; - description: string; -} - export interface SelectionBoxProps { /** * 是否显示操作按钮 @@ -117,36 +110,6 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { const prototype = workspace.componentPrototypes.get(data.name); const isPage = prototype?.type === 'page'; - // 如果声明了 childrenName,提供快捷子元素创建入口 - let insertedList: IInsertedData[] = []; - if (prototype?.childrenName) { - const names = Array.isArray(prototype?.childrenName) - ? prototype.childrenName - : [prototype.childrenName]; - insertedList = names.map((child) => { - const proto = workspace.componentPrototypes.get(child); - return { - name: child, - label: proto?.title || child, - icon: proto?.icon, - description: proto?.help, - }; - }); - } - - let siblingList: IInsertedData[] = []; - if (prototype?.siblingNames) { - siblingList = prototype.siblingNames?.map((item) => { - const proto = workspace.componentPrototypes.get(item); - return { - name: item, - label: proto?.title || item, - icon: proto?.icon, - description: proto?.help, - }; - }); - } - let selectionHelpersAlign: SelectionHelperAlignType = 'top-right'; if (data.bounding) { if (data.bounding.left + data.bounding.width + boundingOffset < designer.viewport.width) { @@ -159,6 +122,7 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { } const isFromCurrentFile = data.filename === workspace.activeViewFile; + const selectedNodeName = workspace.selectSource?.selected?.[0]?.codeId ?? '未选中'; let style: React.CSSProperties; if (data.bounding) { @@ -183,32 +147,18 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) { css={selectionBoxStyle} style={style} > - {siblingList.length > 0 ? ( - <> - { - workspace.insertBeforeSelectedNode(name); - }} - > - - } css={topAddSiblingBtnStyle} /> - - - { - workspace.insertAfterSelectedNode(name); - }} - > - - } css={bottomAddSiblingBtnStyle} /> - - - - ) : null} + <> + + + } css={topAddSiblingBtnStyle} /> + + + + + } css={bottomAddSiblingBtnStyle} /> + + + {showActions && ( {!isPage && actions} - {insertedList.length > 0 && ( - { - workspace.insertToSelectedNode(name); - }} - > + {prototype.hasChildren !== false && ( + } /> - + )} )} @@ -429,105 +374,3 @@ const NameSelector = ({ label, parents = [], onSelect = noop }: NameSelectorProp ); }; - -interface InsertedDropdownProps extends DropdownProps { - title?: string; - options?: IInsertedData[]; - onSelect?: (name: string) => void; -} - -function InsertedDropdown({ - title = '为当前节点添加子元素', - options = [], - onSelect, - ...props -}: InsertedDropdownProps) { - return ( - { - return ( - - - {title} - - - {options.map((item) => ( - onSelect?.(item.name)} - /> - ))} - - - ); - }} - {...props} - /> - ); -} - -const insertedItemStyle = css` - cursor: pointer; - user-select: none; - - &:hover { - background-color: var(--tango-colors-fill2); - } -`; - -function InsertedItem({ - label, - icon, - description, - ...rest -}: HTMLCoralProps<'div'> & Omit) { - let iconNode; - if (!icon) { - iconNode = ; - } else if (icon.startsWith('icon-')) { - iconNode = ; - } else { - iconNode = {label}; - } - - return ( - - - {iconNode} - - - {label} - {description} - - - ); -} 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/helpers/src/types/prototype.ts b/packages/helpers/src/types/prototype.ts index 01245b60..e6b1faca 100644 --- a/packages/helpers/src/types/prototype.ts +++ b/packages/helpers/src/types/prototype.ts @@ -3,6 +3,7 @@ */ import { OptionType } from './advanced'; +import { PartialRecord } from './base'; /** * @deprecated 请使用 IComponentProp 代替 @@ -327,3 +328,19 @@ export interface ITangoConfigJson { autoGenerateComponentId: boolean; }; } + +/** + * 物料类型 + common: '基础组件', + atom: '原子组件', + snippet: '组合', + bizComp: '业务组件', + localComp: '本地组件', + */ +export type MenuKeyType = 'common' | 'atom' | 'snippet' | 'bizComp' | 'localComp'; +export type MenuValueType = Array<{ title: string; items: string[] }>; + +/** + * 菜单项类型 + */ +export type MenuDataType = PartialRecord; diff --git a/packages/ui/package.json b/packages/ui/package.json index 16418c83..45f056ba 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,7 +45,8 @@ "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": { "access": "public", diff --git a/packages/ui/src/collapse-panel.tsx b/packages/ui/src/collapse-panel.tsx index 6d81e80b..5bb00992 100644 --- a/packages/ui/src/collapse-panel.tsx +++ b/packages/ui/src/collapse-panel.tsx @@ -84,7 +84,9 @@ export function CollapsePanel(props: CollapsePanelProps) { borderBottom: 'solid', borderBottomColor: 'line2', } - : {}; + : { + border: 'none!important', + }; return ( @@ -94,7 +96,9 @@ export function CollapsePanel(props: CollapsePanelProps) { justifyContent="space-between" className="CollapsePanelHeader" onClick={() => setCollapsed(!collapsed)} - p="m" + p="l" + paddingTop={'m'} + paddingBottom={'m'} {...stickHeaderProps} {...headerProps} css={headerStyle} diff --git a/packages/ui/src/drag-panel.tsx b/packages/ui/src/drag-panel.tsx new file mode 100644 index 00000000..a11ed52e --- /dev/null +++ b/packages/ui/src/drag-panel.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { Box, Text, styled } from 'coral-system'; +import { Popover, PopoverProps, IconFont } from './'; +import Draggable from 'react-draggable'; +import { CloseOutlined } from '@ant-design/icons'; +import { noop } from '@music163/tango-helpers'; + +const CloseIcon = styled(CloseOutlined)` + cursor: pointer; + margin-left: 10px; + padding: 2px; + font-size: 13px; + &:hover { + color: var(--tango-colors-text1); + background-color: var(--tango-colors-line1); + border-radius: 4px; + } +`; + +interface DragPanelProps extends Omit { + // 标题 + title?: React.ReactNode | string; + // 内容 + body?: React.ReactNode | string; + // 底部 + footer?: ((close: () => void) => React.ReactNode) | React.ReactNode | string; + // 宽度 + width?: number | string; + // 右上角区域 + extra?: React.ReactNode | string; + children?: React.ReactNode; +} + +export function DragPanel({ + title, + footer, + body, + children, + width = 330, + extra, + onOpenChange = noop, + ...props +}: DragPanelProps) { + const [open, setOpen] = useState(false); + + const footerNode = + typeof footer === 'function' + ? footer(() => { + setOpen(false); + onOpenChange(false); + }) + : footer; + + return ( + { + setOpen(innerOpen); + onOpenChange(innerOpen); + }} + overlay={ + + + {/* 头部区域 */} + + + + {title} + + + {extra} + { + setOpen(false); + onOpenChange(false); + }} + /> + + + {/* 主体区域 */} + {body} + {/* 底部 */} + {footer && ( + + {footerNode} + + )} + + + } + {...props} + > + {children} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8f10ba60..eaa4efbe 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,3 +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..e3670c47 --- /dev/null +++ b/packages/ui/src/popover.tsx @@ -0,0 +1,166 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { noop } from '@music163/tango-helpers'; +import { Box } from 'coral-system'; + +export interface PopoverProps { + open?: boolean; + /** + * 浮层内容 + */ + overlay: React.ReactNode; + /** + * 浮层打开或关闭时的回调 + */ + onOpenChange?: (open: boolean) => void; + /** + * 浮层被遮挡时自动调整位置 + */ + autoAdjustOverflow?: boolean; + /** + * 点击蒙层是否允许关闭 + */ + maskClosable?: boolean; + /** + * 手动唤起时的位置 + */ + left?: number; + /** + * 手动唤起时的位置 + */ + top?: number; + /** + * z-index + */ + zIndex?: number; + /** + * popoverStyle + */ + popoverStyle?: React.CSSProperties; + children?: React.ReactNode; +} + +export const Popover: React.FC = ({ + open, + overlay, + maskClosable = false, + autoAdjustOverflow = true, + left: controlledLeft, + top: controlledTop, + children, + popoverStyle, + onOpenChange = noop, + 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 controlledTop === 'number') { + setTop(controlledTop); + } + if (typeof controlledLeft === 'number') { + setLeft(controlledLeft); + } + }, [controlledTop, controlledLeft]); + + useLayoutEffect(() => { + const handleDocumentClick = (e: MouseEvent) => { + if ( + maskClosable && + visible && + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setVisible(false); + onOpenChange(false); + } + }; + + if (maskClosable && visible) { + document.addEventListener('click', handleDocumentClick, true); + } + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [maskClosable, onOpenChange, visible]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + setLeft(x); + setTop(y + 10); + setVisible(true); + onOpenChange(true); + }, + [onOpenChange], + ); + + 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 overlayStyle: React.CSSProperties = useMemo( + () => ({ + display: visible ? 'block' : 'none', + position: 'fixed', + left, + top, + zIndex, + ...popoverStyle, + }), + [left, popoverStyle, top, visible, 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 53454aba..c651b5e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16101,7 +16101,7 @@ react-dom@^17.0.0: object-assign "^4.1.1" scheduler "^0.20.2" -react-draggable@^4.0.3: +react-draggable@^4.0.3, react-draggable@^4.4.5: version "4.4.6" resolved "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.4.6.tgz" integrity sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw== @@ -16195,10 +16195,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" @@ -17697,16 +17697,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17819,7 +17810,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17833,13 +17824,6 @@ strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz" @@ -19376,7 +19360,7 @@ workerpool@^9.1.1: resolved "https://registry.npmmirror.com/workerpool/-/workerpool-9.1.1.tgz#9ba4d534a79a5517c1e1b9d1014151516829be8d" integrity sha512-EFoFTSEo9m4V4wNrwzVRjxnf/E/oBpOzcI/R5CIugJhl9RsCiq525rszo4AtqcjQQoqFdu2E3H82AnbtpaQHvg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19394,15 +19378,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz"