diff --git a/README.md b/README.md index 1c7eb27e92d..305c5852187 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,17 @@ Download and Install [NodeJS](https://nodejs.org/en/download) >= 18.15.0 3. Open [http://localhost:3000](http://localhost:3000) +## ✨ Features + +### Enhanced Sticky Notes +- **Purpose:** Capture long-form thoughts with rich formatting while keeping the note unobtrusive behind flow nodes and connectors. +- **Usage example:** + 1. Drag a sticky note onto the canvas. + 2. Use the resize handles to expand the note in any direction while it is selected—the blue outline and handles disappear as soon as you click outside—then click the palette icon to switch between the five preset colors and toggle the markdown mode to preview formatted content. + 3. Save the flow—the sticky note keeps its size, color, and markdown content when reloaded or duplicated. +- **Dependencies / breaking changes:** No additional dependencies or breaking changes. +- **Layering assurance:** Notes automatically stay behind every agentflow and chatflow node as well as their connectors so they never hide important UI. + ## 🐳 Docker ### Docker Compose diff --git a/packages/ui/src/assets/scss/style.scss b/packages/ui/src/assets/scss/style.scss index bda0dbb7bea..c01c20403cb 100644 --- a/packages/ui/src/assets/scss/style.scss +++ b/packages/ui/src/assets/scss/style.scss @@ -208,6 +208,10 @@ } } +.react-flow__node-stickyNote { + z-index: -1 !important; +} + .spin-animation { animation: spin 1s linear infinite; } diff --git a/packages/ui/src/store/context/ReactFlowContext.jsx b/packages/ui/src/store/context/ReactFlowContext.jsx index d59f28d5081..f9d520e8c0e 100644 --- a/packages/ui/src/store/context/ReactFlowContext.jsx +++ b/packages/ui/src/store/context/ReactFlowContext.jsx @@ -1,7 +1,7 @@ import { createContext, useState } from 'react' import { useDispatch } from 'react-redux' import PropTypes from 'prop-types' -import { getUniqueNodeId, showHideInputParams } from '@/utils/genericHelper' +import { getUniqueNodeId, showHideInputParams, normalizeStickyNoteNodes } from '@/utils/genericHelper' import { cloneDeep, isEqual } from 'lodash' import { SET_DIRTY } from '@/store/actions' @@ -239,7 +239,7 @@ export const ReactFlowContext = ({ children }) => { } } - reactFlowInstance.setNodes([...nodes, duplicatedNode]) + reactFlowInstance.setNodes(normalizeStickyNoteNodes([...nodes, duplicatedNode])) dispatch({ type: SET_DIRTY }) } } diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index ac834c77f19..24005e3433e 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -1,6 +1,62 @@ import { uniq, get, isEqual } from 'lodash' import moment from 'moment' +export const DEFAULT_STICKY_NOTE_COLOR = '#FFE770' +const DEFAULT_STICKY_NOTE_Z_INDEX = -1 +const DEFAULT_NODE_Z_INDEX = 1 + +const isStickyNoteNode = (node) => { + if (!node) return false + + const nodeName = node?.data?.name + return node.type === 'stickyNote' || nodeName === 'stickyNote' || nodeName === 'stickyNoteAgentflow' +} + +export const normalizeStickyNoteNodes = (nodes = []) => + nodes.map((node) => { + if (!node) return node + + if (isStickyNoteNode(node)) { + const color = node?.data?.color || DEFAULT_STICKY_NOTE_COLOR + const currentZIndex = node?.style?.zIndex + + const needsColorUpdate = node?.data?.color !== color + const needsZIndexUpdate = currentZIndex !== DEFAULT_STICKY_NOTE_Z_INDEX + + if (!needsColorUpdate && !needsZIndexUpdate) { + return node + } + + return { + ...node, + data: needsColorUpdate + ? { + ...node.data, + color + } + : node.data, + style: needsZIndexUpdate + ? { + ...node.style, + zIndex: DEFAULT_STICKY_NOTE_Z_INDEX + } + : node.style + } + } + + if (node?.style?.zIndex == null) { + return { + ...node, + style: { + ...node.style, + zIndex: DEFAULT_NODE_Z_INDEX + } + } + } + + return node + }) + export const getUniqueNodeId = (nodeData, nodes) => { let suffix = 0 @@ -223,6 +279,10 @@ export const initNode = (nodeData, newNodeId, isAgentflow) => { nodeData.id = newNodeId + if (nodeData.type === 'StickyNote') { + nodeData.color = nodeData.color || '#FFE770' + } + return nodeData } diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 07bf57df51b..36b84e9e0a3 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -51,7 +51,8 @@ import { initNode, updateOutdatedNodeData, updateOutdatedNodeEdge, - isValidConnectionAgentflowV2 + isValidConnectionAgentflowV2, + normalizeStickyNoteNodes } from '@/utils/genericHelper' import useNotifier from '@/utils/useNotifier' import { usePrompt } from '@/utils/usePrompt' @@ -162,7 +163,7 @@ const AgentflowCanvas = () => { const flowData = JSON.parse(file) const nodes = flowData.nodes || [] - setNodes(nodes) + setNodes(normalizeStickyNoteNodes(nodes)) setEdges(flowData.edges || []) setTimeout(() => setDirty(), 0) } catch (e) { @@ -204,7 +205,7 @@ const AgentflowCanvas = () => { const handleSaveFlow = (chatflowName) => { if (reactFlowInstance) { - const nodes = reactFlowInstance.getNodes().map((node) => { + const nodes = normalizeStickyNoteNodes(reactFlowInstance.getNodes()).map((node) => { const nodeData = cloneDeep(node.data) if (Object.prototype.hasOwnProperty.call(nodeData.inputs, FLOWISE_CREDENTIAL_ID)) { nodeData.credential = nodeData.inputs[FLOWISE_CREDENTIAL_ID] @@ -333,6 +334,7 @@ const AgentflowCanvas = () => { newNode.type = 'iteration' } else if (nodeData.type === 'StickyNote') { newNode.type = 'stickyNote' + newNode.style = { zIndex: 0 } } else { newNode.type = 'agentFlow' } @@ -408,7 +410,8 @@ const AgentflowCanvas = () => { setSelectedNode(newNode) setNodes((nds) => { - return (nds ?? []).concat(newNode).map((node) => { + const updatedNodes = normalizeStickyNoteNodes((nds ?? []).concat(newNode)) + return updatedNodes.map((node) => { if (node.id === newNode.id) { node.data = { ...node.data, @@ -448,7 +451,7 @@ const AgentflowCanvas = () => { } } - setNodes(cloneNodes) + setNodes(normalizeStickyNoteNodes(cloneNodes)) setEdges(cloneEdges.filter((edge) => !toBeRemovedEdges.includes(edge))) setDirty() setIsSyncNodesButtonEnabled(false) @@ -528,7 +531,7 @@ const AgentflowCanvas = () => { if (getSpecificChatflowApi.data) { const chatflow = getSpecificChatflowApi.data const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : [] - setNodes(initialFlow.nodes || []) + setNodes(normalizeStickyNoteNodes(initialFlow.nodes || [])) setEdges(initialFlow.edges || []) dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi.error) { diff --git a/packages/ui/src/views/agentflowsv2/MarketplaceCanvas.jsx b/packages/ui/src/views/agentflowsv2/MarketplaceCanvas.jsx index aa62363c49a..793a7cc5aa9 100644 --- a/packages/ui/src/views/agentflowsv2/MarketplaceCanvas.jsx +++ b/packages/ui/src/views/agentflowsv2/MarketplaceCanvas.jsx @@ -18,6 +18,7 @@ import MarketplaceCanvasHeader from '@/views/marketplaces/MarketplaceCanvasHeade import StickyNote from './StickyNote' import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog' import { flowContext } from '@/store/context/ReactFlowContext' +import { normalizeStickyNoteNodes } from '@/utils/genericHelper' // icons import { IconMagnetFilled, IconMagnetOff, IconArtboard, IconArtboardOff } from '@tabler/icons-react' @@ -52,7 +53,7 @@ const MarketplaceCanvasV2 = () => { useEffect(() => { if (flowData) { const initialFlow = JSON.parse(flowData) - setNodes(initialFlow.nodes || []) + setNodes(normalizeStickyNoteNodes(initialFlow.nodes || [])) setEdges(initialFlow.edges || []) } diff --git a/packages/ui/src/views/agentflowsv2/StickyNote.jsx b/packages/ui/src/views/agentflowsv2/StickyNote.jsx index c154adfa608..52e43e78bb4 100644 --- a/packages/ui/src/views/agentflowsv2/StickyNote.jsx +++ b/packages/ui/src/views/agentflowsv2/StickyNote.jsx @@ -1,19 +1,21 @@ import PropTypes from 'prop-types' -import { useRef, useContext, useState } from 'react' +import { useRef, useContext, useState, useEffect, useMemo } from 'react' import { useSelector } from 'react-redux' -import { NodeToolbar } from 'reactflow' +import { NodeToolbar, NodeResizer } from 'reactflow' // material-ui import { styled, useTheme, alpha, darken, lighten } from '@mui/material/styles' // project imports -import { ButtonGroup, IconButton, Box } from '@mui/material' -import { IconCopy, IconTrash } from '@tabler/icons-react' +import { ButtonGroup, IconButton, Box, Popover, Stack } from '@mui/material' +import { IconCopy, IconMarkdown, IconMarkdownOff, IconPalette, IconTrash } from '@tabler/icons-react' import { Input } from '@/ui-component/input/Input' import MainCard from '@/ui-component/cards/MainCard' +import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown' // const import { flowContext } from '@/store/context/ReactFlowContext' +import { DEFAULT_STICKY_NOTE_COLOR } from '@/utils/genericHelper' const CardWrapper = styled(MainCard)(({ theme }) => ({ background: theme.palette.card.main, @@ -33,7 +35,7 @@ const StyledNodeToolbar = styled(NodeToolbar)(({ theme }) => ({ boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)' })) -const StickyNote = ({ data }) => { +const StickyNote = ({ data, selected }) => { const theme = useTheme() const customization = useSelector((state) => state.customization) const ref = useRef(null) @@ -41,28 +43,92 @@ const StickyNote = ({ data }) => { const { reactFlowInstance, deleteNode, duplicateNode } = useContext(flowContext) const [inputParam] = data.inputParams const [isHovered, setIsHovered] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) + const colorOptions = useMemo( + () => ['#FFE770', '#B4F8C8', '#A0C4FF', '#FFADAD', '#FFD6A5'], + [] + ) + const [noteValue, setNoteValue] = useState(data.inputs?.[inputParam.name] ?? inputParam.default ?? '') - const defaultColor = '#666666' // fallback color if data.color is not present + const defaultColor = DEFAULT_STICKY_NOTE_COLOR // fallback color if data.color is not present const nodeColor = data.color || defaultColor // Get different shades of the color based on state const getStateColor = () => { - if (data.selected) return nodeColor + if (selected) return nodeColor if (isHovered) return alpha(nodeColor, 0.8) return alpha(nodeColor, 0.5) } const getBackgroundColor = () => { if (customization.isDarkMode) { - return isHovered ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8) + return isHovered ? darken(nodeColor, 0.4) : darken(nodeColor, 0.5) + } + return isHovered ? lighten(nodeColor, 0.1) : lighten(nodeColor, 0.2) + } + + useEffect(() => { + if (!data.color) { + data.color = defaultColor } - return isHovered ? lighten(nodeColor, 0.8) : lighten(nodeColor, 0.9) + }, [data, defaultColor]) + + const currentNoteValue = data.inputs?.[inputParam.name] + + useEffect(() => { + const latestValue = currentNoteValue ?? inputParam.default ?? '' + setNoteValue(latestValue) + }, [currentNoteValue, inputParam.default, inputParam.name]) + + const handleToggleEditing = () => { + setIsEditing((prev) => !prev) + } + + const handleColorButtonClick = (event) => { + setAnchorEl(event.currentTarget) + } + + const handleColorSelect = (color) => { + data.color = color + setAnchorEl(null) } return ( -