1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

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.
This commit is contained in:
Nuno Góis 2024-10-16 09:15:40 +01:00 committed by GitHub
parent 6d2b882eb8
commit 9a98f86077
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 334 additions and 22 deletions

View File

@ -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 (
<StyledAIChatContainer>
<StyledChat>
<AIChatHeader
onNew={() => setMessages(initialMessages)}
onClose={() => setOpen(false)}
/>
<StyledChatContent>
<AIChatMessage from='assistant'>
Hello, how can I assist you?
</AIChatMessage>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
<StyledResizable
handlers={['top-left', 'top', 'left']}
minSize={{ width: '270px', height: '200px' }}
maxSize={{ width: '90vw', height: '90vh' }}
defaultSize={{ width: '320px', height: '450px' }}
onResize={scrollToEnd}
>
<StyledChat>
<AIChatHeader
onNew={() => setMessages(initialMessages)}
onClose={() => setOpen(false)}
/>
<StyledChatContent>
<AIChatMessage from='assistant'>
Hello, how can I assist you?
</AIChatMessage>
))}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput onSend={onSend} loading={loading} />
</StyledChat>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
</AIChatMessage>
))}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput onSend={onSend} loading={loading} />
</StyledChat>
</StyledResizable>
</StyledAIChatContainer>
);
};

View File

@ -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<HTMLDivElement> {
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<HTMLDivElement | null>(null);
const [currentSize, setCurrentSize] = useState(defaultSize);
const [animate, setAnimate] = useState(false);
const handleResize = (
e: React.MouseEvent<HTMLDivElement>,
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 (
<StyledResizableWrapper
ref={containerRef}
animate={animate}
{...props}
style={{
width: currentSize.width,
height: currentSize.height,
minWidth: minSize.width,
minHeight: minSize.height,
maxWidth: maxSize.width,
maxHeight: maxSize.height,
}}
>
{children}
{handlers.includes('top-left') && (
<StyledResizeHandle
className='top-left'
onMouseDown={(e) => handleResize(e, 'top-left')}
onDoubleClick={() => handleDoubleClick('top-left')}
/>
)}
{handlers.includes('top-right') && (
<StyledResizeHandle
className='top-right'
onMouseDown={(e) => handleResize(e, 'top-right')}
onDoubleClick={() => handleDoubleClick('top-right')}
/>
)}
{handlers.includes('bottom-left') && (
<StyledResizeHandle
className='bottom-left'
onMouseDown={(e) => handleResize(e, 'bottom-left')}
onDoubleClick={() => handleDoubleClick('bottom-left')}
/>
)}
{handlers.includes('bottom-right') && (
<StyledResizeHandle
className='bottom-right'
onMouseDown={(e) => handleResize(e, 'bottom-right')}
onDoubleClick={() => handleDoubleClick('bottom-right')}
/>
)}
{handlers.includes('top') && (
<StyledResizeHandle
className='top'
onMouseDown={(e) => handleResize(e, 'top')}
onDoubleClick={() => handleDoubleClick('top')}
/>
)}
{handlers.includes('right') && (
<StyledResizeHandle
className='right'
onMouseDown={(e) => handleResize(e, 'right')}
onDoubleClick={() => handleDoubleClick('right')}
/>
)}
{handlers.includes('bottom') && (
<StyledResizeHandle
className='bottom'
onMouseDown={(e) => handleResize(e, 'bottom')}
onDoubleClick={() => handleDoubleClick('bottom')}
/>
)}
{handlers.includes('left') && (
<StyledResizeHandle
className='left'
onMouseDown={(e) => handleResize(e, 'left')}
onDoubleClick={() => handleDoubleClick('left')}
/>
)}
</StyledResizableWrapper>
);
};