From 9a98f86077ce1a52f2990c3dd48c7ab751eae31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 16 Oct 2024 09:15:40 +0100 Subject: [PATCH] chore: make the Unleash AI chat resizable (#8456) https://linear.app/unleash/issue/2-2840/make-the-unleash-ai-chat-window-resizable This PR makes the Unleash AI chat resizable, providing users with a flexible way to adjust the chat window's size. Implements a reusable `Resizable` wrapper component that allows configuration of: - Minimum, maximum, and default sizes. - Customizable resize handlers for each edge and corner of the container. - Optional resize event callbacks. Double-clicking any resize handler maximizes the container along that axis (or both, if it's a corner). If the container is already maximized, double-clicking again will revert it to the default size. --- frontend/src/component/ai/AIChat.tsx | 60 ++-- .../component/common/Resizable/Resizable.tsx | 296 ++++++++++++++++++ 2 files changed, 334 insertions(+), 22 deletions(-) create mode 100644 frontend/src/component/common/Resizable/Resizable.tsx diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx index a2af1867c6..ac085ba4c0 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -13,6 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { AIChatInput } from './AIChatInput'; import { AIChatMessage } from './AIChatMessage'; import { AIChatHeader } from './AIChatHeader'; +import { Resizable } from 'component/common/Resizable/Resizable'; const StyledAIIconContainer = styled('div')(({ theme }) => ({ position: 'fixed', @@ -37,19 +38,26 @@ const StyledAIChatContainer = styled(StyledAIIconContainer)({ right: 10, }); +const StyledResizable = styled(Resizable)(({ theme }) => ({ + boxShadow: theme.boxShadows.popup, + borderRadius: theme.shape.borderRadiusLarge, +})); + const StyledAIIconButton = styled(IconButton)(({ theme }) => ({ background: theme.palette.primary.light, color: theme.palette.primary.contrastText, boxShadow: theme.boxShadows.popup, + transition: 'background 0.3s', '&:hover': { background: theme.palette.primary.dark, }, })); const StyledChat = styled('div')(({ theme }) => ({ - borderRadius: theme.shape.borderRadiusLarge, + display: 'flex', + flex: 1, + flexDirection: 'column', overflow: 'hidden', - boxShadow: theme.boxShadows.popup, background: theme.palette.background.paper, })); @@ -58,9 +66,9 @@ const StyledChatContent = styled('div')(({ theme }) => ({ flexDirection: 'column', padding: theme.spacing(2), paddingBottom: theme.spacing(1), - width: '30vw', - height: '50vh', - overflow: 'auto', + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', })); const initialMessages: ChatMessage[] = [ @@ -134,24 +142,32 @@ export const AIChat = () => { return ( - - setMessages(initialMessages)} - onClose={() => setOpen(false)} - /> - - - Hello, how can I assist you? - - {messages.map(({ role, content }, index) => ( - - {content} + + + setMessages(initialMessages)} + onClose={() => setOpen(false)} + /> + + + Hello, how can I assist you? - ))} -
- - - + {messages.map(({ role, content }, index) => ( + + {content} + + ))} +
+ + + + ); }; diff --git a/frontend/src/component/common/Resizable/Resizable.tsx b/frontend/src/component/common/Resizable/Resizable.tsx new file mode 100644 index 0000000000..8bcaffdf0a --- /dev/null +++ b/frontend/src/component/common/Resizable/Resizable.tsx @@ -0,0 +1,296 @@ +import { styled } from '@mui/material'; +import { type HTMLAttributes, useRef, useState, type ReactNode } from 'react'; + +const StyledResizableWrapper = styled('div', { + shouldForwardProp: (prop) => prop !== 'animate', +})<{ animate: boolean }>(({ animate }) => ({ + display: 'flex', + position: 'relative', + overflow: 'hidden', + transition: animate ? 'width 0.3s, height 0.3s' : 'none', +})); + +const StyledResizeHandle = styled('div')({ + position: 'absolute', + background: 'transparent', + zIndex: 1, + '&.top-left': { + top: 0, + left: 0, + cursor: 'nwse-resize', + width: '10px', + height: '10px', + zIndex: 2, + }, + '&.top-right': { + top: 0, + right: 0, + cursor: 'nesw-resize', + width: '10px', + height: '10px', + zIndex: 2, + }, + '&.bottom-left': { + bottom: 0, + left: 0, + cursor: 'nesw-resize', + width: '10px', + height: '10px', + zIndex: 2, + }, + '&.bottom-right': { + bottom: 0, + right: 0, + cursor: 'nwse-resize', + width: '10px', + height: '10px', + zIndex: 2, + }, + '&.top': { + top: 0, + left: '50%', + cursor: 'ns-resize', + width: '100%', + height: '5px', + transform: 'translateX(-50%)', + }, + '&.right': { + top: '50%', + right: 0, + cursor: 'ew-resize', + width: '5px', + height: '100%', + transform: 'translateY(-50%)', + }, + '&.bottom': { + bottom: 0, + left: '50%', + cursor: 'ns-resize', + width: '100%', + height: '5px', + transform: 'translateX(-50%)', + }, + '&.left': { + top: '50%', + left: 0, + cursor: 'ew-resize', + width: '5px', + height: '100%', + transform: 'translateY(-50%)', + }, +}); + +type Handler = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right'; + +type Size = { width: string; height: string }; + +interface IResizableProps extends HTMLAttributes { + handlers: Handler[]; + minSize: Size; + maxSize: Size; + defaultSize?: Size; + onResize?: () => void; + onResizeEnd?: () => void; + children: ReactNode; +} + +export const Resizable = ({ + handlers, + minSize, + maxSize, + defaultSize = minSize, + onResize, + onResizeEnd, + children, + ...props +}: IResizableProps) => { + const containerRef = useRef(null); + const [currentSize, setCurrentSize] = useState(defaultSize); + const [animate, setAnimate] = useState(false); + + const handleResize = ( + e: React.MouseEvent, + direction: + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right', + ) => { + e.preventDefault(); + + const chatContainer = containerRef.current; + if (!chatContainer) return; + + const startX = e.clientX; + const startY = e.clientY; + const startWidth = chatContainer.offsetWidth; + const startHeight = chatContainer.offsetHeight; + + setAnimate(false); + + const onMouseMove = (moveEvent: MouseEvent) => { + let newWidth = startWidth; + let newHeight = startHeight; + + if (direction.includes('top')) { + newHeight = Math.max( + Number.parseInt(minSize.height), + startHeight - (moveEvent.clientY - startY), + ); + } + + if (direction.includes('bottom')) { + newHeight = Math.max( + Number.parseInt(minSize.height), + startHeight + (moveEvent.clientY - startY), + ); + } + + if (direction.includes('left')) { + newWidth = Math.max( + Number.parseInt(minSize.width), + startWidth - (moveEvent.clientX - startX), + ); + } + + if (direction.includes('right')) { + newWidth = Math.max( + Number.parseInt(minSize.width), + startWidth + (moveEvent.clientX - startX), + ); + } + + setCurrentSize({ + width: `${newWidth}px`, + height: `${newHeight}px`, + }); + + onResize?.(); + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + onResizeEnd?.(); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + const handleDoubleClick = (direction: Handler) => { + const chatContainer = containerRef.current; + if (!chatContainer) return; + + const currentWidth = chatContainer.style.width; + const currentHeight = chatContainer.style.height; + + setAnimate(true); + + if (direction.includes('top') || direction.includes('bottom')) { + if (currentHeight === maxSize.height) { + chatContainer.style.height = defaultSize.height; + } else { + chatContainer.style.height = maxSize.height; + } + } + + if (direction.includes('left') || direction.includes('right')) { + if (currentWidth === maxSize.width) { + chatContainer.style.width = defaultSize.width; + } else { + chatContainer.style.width = maxSize.width; + } + } + + onResizeEnd?.(); + }; + + return ( + + {children} + + {handlers.includes('top-left') && ( + handleResize(e, 'top-left')} + onDoubleClick={() => handleDoubleClick('top-left')} + /> + )} + {handlers.includes('top-right') && ( + handleResize(e, 'top-right')} + onDoubleClick={() => handleDoubleClick('top-right')} + /> + )} + {handlers.includes('bottom-left') && ( + handleResize(e, 'bottom-left')} + onDoubleClick={() => handleDoubleClick('bottom-left')} + /> + )} + {handlers.includes('bottom-right') && ( + handleResize(e, 'bottom-right')} + onDoubleClick={() => handleDoubleClick('bottom-right')} + /> + )} + {handlers.includes('top') && ( + handleResize(e, 'top')} + onDoubleClick={() => handleDoubleClick('top')} + /> + )} + {handlers.includes('right') && ( + handleResize(e, 'right')} + onDoubleClick={() => handleDoubleClick('right')} + /> + )} + {handlers.includes('bottom') && ( + handleResize(e, 'bottom')} + onDoubleClick={() => handleDoubleClick('bottom')} + /> + )} + {handlers.includes('left') && ( + handleResize(e, 'left')} + onDoubleClick={() => handleDoubleClick('left')} + /> + )} + + ); +};