diff --git a/apps/demo-free-layout/package.json b/apps/demo-free-layout/package.json index e7462259..b502c230 100644 --- a/apps/demo-free-layout/package.json +++ b/apps/demo-free-layout/package.json @@ -1,63 +1,64 @@ { - "name": "@flowgram.ai/demo-free-layout", - "version": "0.1.0", - "description": "", - "keywords": [], - "license": "MIT", - "main": "./src/index.ts", - "files": [ - "src/", - ".eslintrc.js", - ".gitignore", - "index.html", - "package.json", - "rsbuild.config.ts", - "tsconfig.json" - ], - "scripts": { - "build": "exit 0", - "build:fast": "exit 0", - "build:watch": "exit 0", - "clean": "rimraf dist", - "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open", - "lint": "eslint ./src --cache", - "lint:fix": "eslint ./src --fix", - "start": "cross-env NODE_ENV=development rsbuild dev --open", - "test": "exit", - "test:cov": "exit", - "watch": "exit 0" - }, - "dependencies": { - "@douyinfe/semi-icons": "^2.72.3", - "@douyinfe/semi-ui": "^2.72.3", - "@flowgram.ai/free-layout-editor": "workspace:*", - "@flowgram.ai/free-snap-plugin": "workspace:*", - "@flowgram.ai/free-lines-plugin": "workspace:*", - "@flowgram.ai/free-node-panel-plugin": "workspace:*", - "@flowgram.ai/minimap-plugin": "workspace:*", - "@flowgram.ai/free-container-plugin": "workspace:*", - "lodash-es": "^4.17.21", - "nanoid": "^4.0.2", - "react": "^18", - "react-dom": "^18", - "styled-components": "^5" - }, - "devDependencies": { - "@flowgram.ai/ts-config": "workspace:*", - "@flowgram.ai/eslint-config": "workspace:*", - "@rsbuild/core": "^1.2.16", - "@rsbuild/plugin-react": "^1.1.1", - "@rsbuild/plugin-less": "^1.1.1", - "@types/lodash-es": "^4.17.12", - "@types/node": "^18", - "@types/react": "^18", - "@types/react-dom": "^18", - "@types/styled-components": "^5", - "eslint": "^8.54.0", - "cross-env": "~7.0.3" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - } + "name": "@flowgram.ai/demo-free-layout", + "version": "0.1.0", + "description": "", + "keywords": [], + "license": "MIT", + "main": "./src/index.ts", + "files": [ + "src/", + ".eslintrc.js", + ".gitignore", + "index.html", + "package.json", + "rsbuild.config.ts", + "tsconfig.json" + ], + "scripts": { + "build": "exit 0", + "build:fast": "exit 0", + "build:watch": "exit 0", + "clean": "rimraf dist", + "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open", + "lint": "eslint ./src --cache", + "lint:fix": "eslint ./src --fix", + "start": "cross-env NODE_ENV=development rsbuild dev --open", + "test": "exit", + "test:cov": "exit", + "watch": "exit 0" + }, + "dependencies": { + "@douyinfe/semi-icons": "^2.72.3", + "@douyinfe/semi-ui": "^2.72.3", + "@flowgram.ai/free-layout-editor": "workspace:*", + "@flowgram.ai/free-snap-plugin": "workspace:*", + "@flowgram.ai/free-lines-plugin": "workspace:*", + "@flowgram.ai/free-node-panel-plugin": "workspace:*", + "@flowgram.ai/minimap-plugin": "workspace:*", + "@flowgram.ai/free-container-plugin": "workspace:*", + "@flowgram.ai/free-group-plugin": "workspace:*", + "lodash-es": "^4.17.21", + "nanoid": "^4.0.2", + "react": "^18", + "react-dom": "^18", + "styled-components": "^5" + }, + "devDependencies": { + "@flowgram.ai/ts-config": "workspace:*", + "@flowgram.ai/eslint-config": "workspace:*", + "@rsbuild/core": "^1.2.16", + "@rsbuild/plugin-react": "^1.1.1", + "@rsbuild/plugin-less": "^1.1.1", + "@types/lodash-es": "^4.17.12", + "@types/node": "^18", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/styled-components": "^5", + "eslint": "^8.54.0", + "cross-env": "~7.0.3" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } } diff --git a/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx b/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx index 7720024b..6217538b 100644 --- a/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx +++ b/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx @@ -26,6 +26,8 @@ export const NodeWrapper: React.FC = (props) => { const form = nodeRender.form; const ctx = useClientContext(); + const portsRender = ports.map((p) => ); + return ( <> = (props) => { > {children} - {ports.map((p) => ( - - ))} + {portsRender} ); }; diff --git a/apps/demo-free-layout/src/components/comment/components/container.tsx b/apps/demo-free-layout/src/components/comment/components/container.tsx index dcaca085..b39667ac 100644 --- a/apps/demo-free-layout/src/components/comment/components/container.tsx +++ b/apps/demo-free-layout/src/components/comment/components/container.tsx @@ -14,13 +14,13 @@ export const CommentContainer: FC = (props) => { scrollbarWidth: 'thin', scrollbarColor: 'rgb(159 159 158 / 65%) transparent', // 针对 WebKit 浏览器(如 Chrome、Safari)的样式 - '&::-webkit-scrollbar': { + '&:WebkitScrollbar': { width: '4px', }, - '&::-webkit-scrollbar-track': { + '&::WebkitScrollbarTrack': { background: 'transparent', }, - '&::-webkit-scrollbar-thumb': { + '&::WebkitScrollbarThumb': { backgroundColor: 'rgb(159 159 158 / 65%)', borderRadius: '20px', border: '2px solid transparent', diff --git a/apps/demo-free-layout/src/components/comment/components/index.css b/apps/demo-free-layout/src/components/comment/components/index.css index 4b4a9b1a..429902a0 100644 --- a/apps/demo-free-layout/src/components/comment/components/index.css +++ b/apps/demo-free-layout/src/components/comment/components/index.css @@ -68,3 +68,37 @@ resize: none; outline: none; } + +.workflow-comment-more-button { + position: absolute; + right: 6px; +} + +.workflow-comment-more-button > .semi-button { + color: rgba(255, 255, 255, 0); + background: none; +} + +.workflow-comment-more-button > .semi-button:hover { + color: #ffa100; + background: #fbf2d2cc; + backdrop-filter: blur(1px); +} + +.workflow-comment-more-button-focused > .semi-button:hover { + color: #ff811a; + background: #ffe3cecc; + backdrop-filter: blur(1px); +} + +.workflow-comment-more-button > .semi-button:active { + color: #f2b600; + background: #ede5c7cc; + backdrop-filter: blur(1px); +} + +.workflow-comment-more-button-focused > .semi-button:active { + color: #ff811a; + background: #eed5c1cc; + backdrop-filter: blur(1px); +} diff --git a/apps/demo-free-layout/src/components/comment/components/more-button.tsx b/apps/demo-free-layout/src/components/comment/components/more-button.tsx new file mode 100644 index 00000000..ba28b49a --- /dev/null +++ b/apps/demo-free-layout/src/components/comment/components/more-button.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; + +import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor'; + +import { NodeMenu } from '../../node-menu'; + +interface IMoreButton { + node: WorkflowNodeEntity; + focused: boolean; + deleteNode: () => void; +} + +export const MoreButton: FC = ({ node, focused, deleteNode }) => ( +
+ +
+); diff --git a/apps/demo-free-layout/src/components/comment/components/render.tsx b/apps/demo-free-layout/src/components/comment/components/render.tsx index f8052e45..de9a7ad5 100644 --- a/apps/demo-free-layout/src/components/comment/components/render.tsx +++ b/apps/demo-free-layout/src/components/comment/components/render.tsx @@ -14,6 +14,7 @@ import { useOverflow } from '../hooks/use-overflow'; import { useModel } from '../hooks/use-model'; import { useSize } from '../hooks'; import { CommentEditorFormField } from '../constant'; +import { MoreButton } from './more-button'; import { CommentEditor } from './editor'; import { ContentDragArea } from './content-drag-area'; import { CommentContainer } from './container'; @@ -26,7 +27,7 @@ export const CommentRender: FC<{ const { node } = props; const model = useModel(); - const { selected: focused, selectNode, nodeRef } = useNodeRender(); + const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender(); const formModel = node.getData(FlowNodeFormData).getFormModel(); const formControl = formModel?.formControl; @@ -65,6 +66,8 @@ export const CommentRender: FC<{ {/* 内容拖拽区域(点击后隐藏) */} + {/* 更多按钮 */} + )} diff --git a/apps/demo-free-layout/src/components/group/color.ts b/apps/demo-free-layout/src/components/group/color.ts new file mode 100644 index 00000000..4a079036 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/color.ts @@ -0,0 +1,100 @@ +type GroupColor = { + '50': string; + '300': string; + '400': string; +}; + +export const defaultColor = 'Blue'; + +export const groupColors: Record = { + Red: { + '50': '#fef2f2', + '300': '#fca5a5', + '400': '#f87171', + }, + Orange: { + '50': '#fff7ed', + '300': '#fdba74', + '400': '#fb923c', + }, + Amber: { + '50': '#fffbeb', + '300': '#fcd34d', + '400': '#fbbf24', + }, + Yellow: { + '50': '#fef9c3', + '300': '#fde047', + '400': '#facc15', + }, + Lime: { + '50': '#f7fee7', + '300': '#bef264', + '400': '#a3e635', + }, + Green: { + '50': '#f0fdf4', + '300': '#86efac', + '400': '#4ade80', + }, + Emerald: { + '50': '#ecfdf5', + '300': '#6ee7b7', + '400': '#34d399', + }, + Teal: { + '50': '#f0fdfa', + '300': '#5eead4', + '400': '#2dd4bf', + }, + Cyan: { + '50': '#ecfeff', + '300': '#67e8f9', + '400': '#22d3ee', + }, + Sky: { + '50': '#ecfeff', + '300': '#7dd3fc', + '400': '#38bdf8', + }, + Blue: { + '50': '#eff6ff', + '300': '#93c5fd', + '400': '#60a5fa', + }, + Indigo: { + '50': '#eef2ff', + '300': '#a5b4fc', + '400': '#818cf8', + }, + Violet: { + '50': '#f5f3ff', + '300': '#c4b5fd', + '400': '#a78bfa', + }, + Purple: { + '50': '#faf5ff', + '300': '#d8b4fe', + '400': '#c084fc', + }, + Fuchsia: { + '50': '#fdf4ff', + '300': '#f0abfc', + '400': '#e879f9', + }, + Pink: { + '50': '#fdf2f8', + '300': '#f9a8d4', + '400': '#f472b6', + }, + Rose: { + '50': '#fff1f2', + '300': '#fda4af', + '400': '#fb7185', + }, + Gray: { + '50': '#f9fafb', + '300': '#d1d5db', + '400': '#9ca3af', + }, +}; diff --git a/apps/demo-free-layout/src/components/group/components/background.tsx b/apps/demo-free-layout/src/components/group/components/background.tsx new file mode 100644 index 00000000..3230dcbe --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/background.tsx @@ -0,0 +1,49 @@ +import { CSSProperties, FC, useEffect } from 'react'; + +import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor'; + +import { GroupField } from '../constant'; +import { defaultColor, groupColors } from '../color'; + +interface GroupBackgroundProps { + node: WorkflowNodeEntity; + style?: CSSProperties; +} + +export const GroupBackground: FC = ({ node, style }) => { + const colorName = useWatch(GroupField.Color) ?? defaultColor; + const color = groupColors[colorName]; + + useEffect(() => { + const styleElement = document.createElement('style'); + + // 使用独特的选择器 + const styleContent = ` + .workflow-group-render[data-group-id="${node.id}"] .workflow-group-background { + border: 1px solid ${color['300']}; + } + + .workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background { + border: 1px solid ${color['400']}; + } + `; + + styleElement.textContent = styleContent; + document.head.appendChild(styleElement); + + return () => { + styleElement.remove(); + }; + }, [color]); + + return ( +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/group/components/color.tsx b/apps/demo-free-layout/src/components/group/components/color.tsx new file mode 100644 index 00000000..5f656de0 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/color.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react'; + +import { Field } from '@flowgram.ai/free-layout-editor'; +import { Popover, Tooltip } from '@douyinfe/semi-ui'; + +import { GroupField } from '../constant'; +import { defaultColor, groupColors } from '../color'; + +export const GroupColor: FC = () => ( + name={GroupField.Color}> + {({ field }) => { + const colorName = field.value ?? defaultColor; + return ( + + {Object.entries(groupColors).map(([name, color]) => ( + + field.onChange(name)} + /> + + ))} +
+ } + > + + + ); + }} + +); diff --git a/apps/demo-free-layout/src/components/group/components/header.tsx b/apps/demo-free-layout/src/components/group/components/header.tsx new file mode 100644 index 00000000..08c870d3 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/header.tsx @@ -0,0 +1,40 @@ +import type { FC, ReactNode, MouseEvent, CSSProperties } from 'react'; + +import { useWatch } from '@flowgram.ai/free-layout-editor'; + +import { GroupField } from '../constant'; +import { defaultColor, groupColors } from '../color'; + +interface GroupHeaderProps { + onMouseDown: (e: MouseEvent) => void; + onFocus: () => void; + onBlur: () => void; + children: ReactNode; + style?: CSSProperties; +} + +export const GroupHeader: FC = ({ + onMouseDown, + onFocus, + onBlur, + children, + style, +}) => { + const colorName = useWatch(GroupField.Color) ?? defaultColor; + const color = groupColors[colorName]; + return ( +
+ {children} +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/group/components/icon-group.tsx b/apps/demo-free-layout/src/components/group/components/icon-group.tsx new file mode 100644 index 00000000..1a51409f --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/icon-group.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; + +interface IconGroupProps { + size?: number; +} + +export const IconGroup: FC = ({ size }) => ( + + + +); + +export const IconUngroup: FC = ({ size }) => ( + + + +); diff --git a/apps/demo-free-layout/src/components/group/components/index.ts b/apps/demo-free-layout/src/components/group/components/index.ts new file mode 100644 index 00000000..f217671f --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/index.ts @@ -0,0 +1,2 @@ +export { GroupNodeRender } from './node-render'; +export { IconGroup } from './icon-group'; diff --git a/apps/demo-free-layout/src/components/group/components/node-render.tsx b/apps/demo-free-layout/src/components/group/components/node-render.tsx new file mode 100644 index 00000000..23c42495 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/node-render.tsx @@ -0,0 +1,66 @@ +import { + FlowNodeFormData, + Form, + FormModelV2, + useNodeRender, +} from '@flowgram.ai/free-layout-editor'; +import { useNodeSize } from '@flowgram.ai/free-container-plugin'; + +import { HEADER_HEIGHT, HEADER_PADDING } from '../constant'; +import { UngroupButton } from './ungroup'; +import { GroupTools } from './tools'; +import { GroupTips } from './tips'; +import { GroupHeader } from './header'; +import { GroupBackground } from './background'; + +export const GroupNodeRender = () => { + const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender(); + const nodeSize = useNodeSize(); + const formModel = node.getData(FlowNodeFormData).getFormModel(); + const formControl = formModel?.formControl; + + const { height, width } = nodeSize ?? {}; + const nodeHeight = height ?? 0; + return ( +
{ + selectNode(e); + }} + style={{ + width, + height, + }} + > +
+ <> + { + startDrag(e); + }} + onFocus={onFocus} + onBlur={onBlur} + style={{ + height: HEADER_HEIGHT, + }} + > + + + + + + + +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/group/components/tips/global-store.ts b/apps/demo-free-layout/src/components/group/components/tips/global-store.ts new file mode 100644 index 00000000..b9a18c50 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/global-store.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/naming-convention -- no need */ + +const STORAGE_KEY = 'workflow-move-into-group-tip-visible'; +const STORAGE_VALUE = 'false'; + +export class TipsGlobalStore { + private static _instance?: TipsGlobalStore; + + public static get instance(): TipsGlobalStore { + if (!this._instance) { + this._instance = new TipsGlobalStore(); + } + return this._instance; + } + + private closed = false; + + public isClosed(): boolean { + return this.isCloseForever() || this.closed; + } + + public close(): void { + this.closed = true; + } + + public isCloseForever(): boolean { + return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE; + } + + public closeForever(): void { + localStorage.setItem(STORAGE_KEY, STORAGE_VALUE); + } +} diff --git a/apps/demo-free-layout/src/components/group/components/tips/icon-close.tsx b/apps/demo-free-layout/src/components/group/components/tips/icon-close.tsx new file mode 100644 index 00000000..366a3fbf --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/icon-close.tsx @@ -0,0 +1,9 @@ +export const IconClose = () => ( + + + +); diff --git a/apps/demo-free-layout/src/components/group/components/tips/index.tsx b/apps/demo-free-layout/src/components/group/components/tips/index.tsx new file mode 100644 index 00000000..9fac353c --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/index.tsx @@ -0,0 +1,36 @@ +import { useControlTips } from './use-control'; +import { GroupTipsStyle } from './style'; +import { isMacOS } from './is-mac-os'; +import { IconClose } from './icon-close'; + +export const GroupTips = () => { + const { visible, close, closeForever } = useControlTips(); + + if (!visible) { + return null; + } + + return ( + +
+
+

{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}

+
+
+
+

+ Never Remind +

+
+ +
+
+
+ + ); +}; diff --git a/apps/demo-free-layout/src/components/group/components/tips/is-mac-os.ts b/apps/demo-free-layout/src/components/group/components/tips/is-mac-os.ts new file mode 100644 index 00000000..d0bb76ae --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/is-mac-os.ts @@ -0,0 +1 @@ +export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent); diff --git a/apps/demo-free-layout/src/components/group/components/tips/style.ts b/apps/demo-free-layout/src/components/group/components/tips/style.ts new file mode 100644 index 00000000..7e704b5d --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/style.ts @@ -0,0 +1,74 @@ +import styled from 'styled-components'; + +export const GroupTipsStyle = styled.div` + position: absolute; + top: 35px; + + width: 100%; + height: 28px; + white-space: nowrap; + pointer-events: auto; + + .container { + display: inline-flex; + justify-content: center; + height: 100%; + width: 100%; + background-color: rgb(255 255 255); + border-radius: 8px 8px 0 0; + + .content { + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: flex-start; + + width: fit-content; + height: 100%; + padding: 0 12px; + + .text { + font-size: 14px; + font-weight: 400; + font-style: normal; + line-height: 20px; + color: rgba(15, 21, 40, 82%); + text-overflow: ellipsis; + margin: 0; + } + + .space { + width: 128px; + } + } + + .actions { + display: flex; + gap: 8px; + align-items: center; + + height: 28px; + padding: 0 12px; + + .close-forever { + cursor: pointer; + + padding: 0 3px; + + font-size: 12px; + font-weight: 400; + font-style: normal; + line-height: 12px; + color: rgba(32, 41, 69, 62%); + margin: 0; + } + + .close { + display: flex; + cursor: pointer; + height: 100%; + align-items: center; + } + } + } +`; diff --git a/apps/demo-free-layout/src/components/group/components/tips/use-control.ts b/apps/demo-free-layout/src/components/group/components/tips/use-control.ts new file mode 100644 index 00000000..947ea2d9 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tips/use-control.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor'; +import { + NodeIntoContainerService, + NodeIntoContainerType, +} from '@flowgram.ai/free-container-plugin'; + +import { TipsGlobalStore } from './global-store'; + +export const useControlTips = () => { + const node = useCurrentEntity(); + const [visible, setVisible] = useState(false); + const globalStore = TipsGlobalStore.instance; + + const nodeIntoContainerService = useService(NodeIntoContainerService); + + const show = useCallback(() => { + if (globalStore.isClosed()) { + return; + } + + setVisible(true); + }, [globalStore]); + + const close = useCallback(() => { + globalStore.close(); + setVisible(false); + }, [globalStore]); + + const closeForever = useCallback(() => { + globalStore.closeForever(); + close(); + }, [close, globalStore]); + + useEffect(() => { + // 监听移入 + const inDisposer = nodeIntoContainerService.on((e) => { + if (e.type !== NodeIntoContainerType.In) { + return; + } + if (e.targetContainer === node) { + show(); + } + }); + // 监听移出事件 + const outDisposer = nodeIntoContainerService.on((e) => { + if (e.type !== NodeIntoContainerType.Out) { + return; + } + if (e.sourceContainer === node && !node.blocks.length) { + setVisible(false); + } + }); + return () => { + inDisposer.dispose(); + outDisposer.dispose(); + }; + }, [nodeIntoContainerService, node, show, close, visible]); + + return { + visible, + close, + closeForever, + }; +}; diff --git a/apps/demo-free-layout/src/components/group/components/title.tsx b/apps/demo-free-layout/src/components/group/components/title.tsx new file mode 100644 index 00000000..66e7e9c8 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/title.tsx @@ -0,0 +1,33 @@ +import { FC, useState } from 'react'; + +import { Field } from '@flowgram.ai/free-layout-editor'; +import { Input } from '@douyinfe/semi-ui'; + +import { GroupField } from '../constant'; + +export const GroupTitle: FC = () => { + const [inputting, setInputting] = useState(false); + return ( + name={GroupField.Title}> + {({ field }) => + inputting ? ( + e.stopPropagation()} + onBlur={() => setInputting(false)} + draggable={false} + onEnterPress={() => setInputting(false)} + /> + ) : ( +

setInputting(true)}> + {field.value ?? 'Group'} +

+ ) + } + + ); +}; diff --git a/apps/demo-free-layout/src/components/group/components/tools.tsx b/apps/demo-free-layout/src/components/group/components/tools.tsx new file mode 100644 index 00000000..869deb5f --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/tools.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; + +import { IconHandle } from '@douyinfe/semi-icons'; + +import { GroupTitle } from './title'; +import { GroupColor } from './color'; + +export const GroupTools: FC = () => ( +
+ + + +
+); diff --git a/apps/demo-free-layout/src/components/group/components/ungroup.tsx b/apps/demo-free-layout/src/components/group/components/ungroup.tsx new file mode 100644 index 00000000..57e7f7ab --- /dev/null +++ b/apps/demo-free-layout/src/components/group/components/ungroup.tsx @@ -0,0 +1,31 @@ +import { CSSProperties, FC } from 'react'; + +import { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor'; +import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin'; +import { Button, Tooltip } from '@douyinfe/semi-ui'; + +import { IconUngroup } from './icon-group'; + +interface UngroupButtonProps { + node: WorkflowNodeEntity; + style?: CSSProperties; +} + +export const UngroupButton: FC = ({ node, style }) => { + const commandRegistry = useService(CommandRegistry); + return ( + +
+
+
+ ); +}; diff --git a/apps/demo-free-layout/src/components/group/constant.ts b/apps/demo-free-layout/src/components/group/constant.ts new file mode 100644 index 00000000..e51a4a07 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/constant.ts @@ -0,0 +1,7 @@ +export const HEADER_HEIGHT = 30; +export const HEADER_PADDING = 5; + +export enum GroupField { + Title = 'title', + Color = 'color', +} diff --git a/apps/demo-free-layout/src/components/group/index.css b/apps/demo-free-layout/src/components/group/index.css new file mode 100644 index 00000000..adbfa90b --- /dev/null +++ b/apps/demo-free-layout/src/components/group/index.css @@ -0,0 +1,109 @@ +.workflow-group-render { + border-radius: 8px; + pointer-events: none; +} + +.workflow-group-header { + height: 30px; + width: fit-content; + background-color: #fefce8; + border: 1px solid #facc15; + border-radius: 8px; + padding-right: 8px; + pointer-events: auto; +} + +.workflow-group-ungroup { + display: flex; + justify-content: center; + align-items: center; + height: 30px; + width: 30px; + position: absolute; + top: 35px; + right: 0; + border-radius: 8px; + cursor: pointer; + pointer-events: auto; +} + +.workflow-group-ungroup .semi-button { + color: #9ca3af; +} + +.workflow-group-ungroup:hover .semi-button { + color: #374151; +} + +.workflow-group-background { + position: absolute; + pointer-events: none; + top: 0; + background-color: #fddf4729; + border: 1px solid #fde047; + border-radius: 8px; + width: 100%; +} + +.workflow-group-render.selected .workflow-group-background { + border: 1px solid #facc15; +} + +.workflow-group-tools { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 4px; + height: 100%; + cursor: move; + color: oklch(44.6% 0.043 257.281); + font-size: 14px; +} +.workflow-group-title { + margin: 0; + max-width: 242px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.workflow-group-tools-drag { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding-left: 4px; +} + +.workflow-group-color { + width: 16px; + height: 16px; + border-radius: 8px; + background-color: #fde047; + margin-left: 4px; + cursor: pointer; +} + +.workflow-group-title-input { + width: 242px; + border: none; + color: #374151; +} + +.workflow-group-color-palette { + display: grid; + grid-template-columns: repeat(6, 24px); + gap: 12px; + margin: 8px; + padding: 8px; +} + +.workflow-group-color-item { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #fde047; + cursor: pointer; + border: 3px solid; +} diff --git a/apps/demo-free-layout/src/components/group/index.ts b/apps/demo-free-layout/src/components/group/index.ts new file mode 100644 index 00000000..76a9f010 --- /dev/null +++ b/apps/demo-free-layout/src/components/group/index.ts @@ -0,0 +1,4 @@ +import './index.css'; + +export { GroupNodeRender } from './components'; +export { IconGroup } from './components'; diff --git a/apps/demo-free-layout/src/components/index.ts b/apps/demo-free-layout/src/components/index.ts index a746ccfb..2dd8c647 100644 --- a/apps/demo-free-layout/src/components/index.ts +++ b/apps/demo-free-layout/src/components/index.ts @@ -2,3 +2,4 @@ export * from './base-node'; export * from './line-add-button'; export * from './node-panel'; export * from './comment'; +export * from './group'; diff --git a/apps/demo-free-layout/src/components/node-menu/index.tsx b/apps/demo-free-layout/src/components/node-menu/index.tsx new file mode 100644 index 00000000..f377b141 --- /dev/null +++ b/apps/demo-free-layout/src/components/node-menu/index.tsx @@ -0,0 +1,113 @@ +import { FC, useCallback, useState, type MouseEvent } from 'react'; + +import { + delay, + useClientContext, + useService, + WorkflowDragService, + WorkflowNodeEntity, + WorkflowSelectService, +} from '@flowgram.ai/free-layout-editor'; +import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin'; +import { IconButton, Dropdown } from '@douyinfe/semi-ui'; +import { IconMore } from '@douyinfe/semi-icons'; + +import { FlowNodeRegistry } from '../../typings'; +import { PasteShortcut } from '../../shortcuts/paste'; +import { CopyShortcut } from '../../shortcuts/copy'; + +interface NodeMenuProps { + node: WorkflowNodeEntity; + deleteNode: () => void; +} + +export const NodeMenu: FC = ({ node, deleteNode }) => { + const [visible, setVisible] = useState(true); + const clientContext = useClientContext(); + const registry = node.getNodeRegistry(); + const nodeIntoContainerService = useService(NodeIntoContainerService); + const selectService = useService(WorkflowSelectService); + const dragService = useService(WorkflowDragService); + const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node); + + const rerenderMenu = useCallback(() => { + // force destroy component - 强制销毁组件触发重新渲染 + setVisible(false); + requestAnimationFrame(() => { + setVisible(true); + }); + }, []); + + const handleMoveOut = useCallback( + async (e: MouseEvent) => { + e.stopPropagation(); + const sourceParent = node.parent; + // move out of container - 移出容器 + nodeIntoContainerService.moveOutContainer({ node }); + // clear invalid lines - 清除非法线条 + await nodeIntoContainerService.clearInvalidLines({ + dragNode: node, + sourceParent, + }); + rerenderMenu(); + await delay(16); + // select node - 选中节点 + selectService.selectNode(node); + // start drag node - 开始拖拽 + dragService.startDragSelectedNodes(e); + }, + [nodeIntoContainerService, node, rerenderMenu] + ); + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + const copyShortcut = new CopyShortcut(clientContext); + const pasteShortcut = new PasteShortcut(clientContext); + const data = copyShortcut.toClipboardData([node]); + pasteShortcut.apply(data); + e.stopPropagation(); // Disable clicking prevents the sidebar from opening + }, + [clientContext, node] + ); + + const handleDelete = useCallback( + (e: React.MouseEvent) => { + deleteNode(); + e.stopPropagation(); // Disable clicking prevents the sidebar from opening + }, + [clientContext, node] + ); + + if (!visible) { + return; + } + + return ( + + {canMoveOut && Move out} + + Create Copy + + + Delete + + + } + > + } + onClick={(e) => e.stopPropagation()} + /> + + ); +}; diff --git a/apps/demo-free-layout/src/components/selector-box-popover/index.tsx b/apps/demo-free-layout/src/components/selector-box-popover/index.tsx index 482b56c2..8a0c3360 100644 --- a/apps/demo-free-layout/src/components/selector-box-popover/index.tsx +++ b/apps/demo-free-layout/src/components/selector-box-popover/index.tsx @@ -1,9 +1,11 @@ import { FunctionComponent } from 'react'; import { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor'; +import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin'; import { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui'; import { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons'; +import { IconGroup } from '../group'; import { FlowCommandId } from '../../shortcuts/constants'; const BUTTON_HEIGHT = 24; @@ -54,6 +56,18 @@ export const SelectorBoxPopover: FunctionComponent = ({ /> + +