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:
parent
6d2b882eb8
commit
9a98f86077
@ -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>
|
||||
);
|
||||
};
|
||||
|
296
frontend/src/component/common/Resizable/Resizable.tsx
Normal file
296
frontend/src/component/common/Resizable/Resizable.tsx
Normal 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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user