Widget: Allow resizing from all corners and sides (#15680)

https://github.com/user-attachments/assets/3e4e1da2-87e6-440e-8371-835ef6eccf59
This commit is contained in:
Raphaël Bosi
2025-11-07 15:28:39 +01:00
committed by GitHub
parent 2274a937bd
commit 44d6ec2594
11 changed files with 325 additions and 63 deletions
@@ -1,6 +1,7 @@
import { PageLayoutGridLayoutDragSelector } from '@/page-layout/components/PageLayoutGridLayoutDragSelector';
import { PageLayoutGridOverlay } from '@/page-layout/components/PageLayoutGridOverlay';
import { PageLayoutGridResizeHandle } from '@/page-layout/components/PageLayoutGridResizeHandle';
import { ReactGridLayoutCardWrapper } from '@/page-layout/components/ReactGridLayoutCardWrapper';
import { EMPTY_LAYOUT } from '@/page-layout/constants/EmptyLayout';
import {
PAGE_LAYOUT_CONFIG,
@@ -13,6 +14,7 @@ import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
import { addPendingPlaceholderToLayouts } from '@/page-layout/utils/addPendingPlaceholderToLayouts';
import { filterPendingPlaceholderFromLayouts } from '@/page-layout/utils/filterPendingPlaceholderFromLayouts';
import { prepareGridLayoutItemsWithPlaceholders } from '@/page-layout/utils/prepareGridLayoutItemsWithPlaceholders';
@@ -78,6 +80,10 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
pageLayoutDraggingWidgetIdComponentState,
);
const setResizingWidgetId = useSetRecoilComponentState(
pageLayoutResizingWidgetIdComponentState,
);
const { handleLayoutChange } = usePageLayoutHandleLayoutChange();
const handleLayoutChangeWithoutPendingPlaceholder = (
@@ -165,13 +171,19 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
resizeHandle={
isPageLayoutInEditMode ? <PageLayoutGridResizeHandle /> : undefined
}
resizeHandles={['se']}
resizeHandles={['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']}
onDragStart={(_layout, _oldItem, newItem) => {
setDraggingWidgetId(newItem.i);
}}
onDragStop={() => {
setDraggingWidgetId(null);
}}
onResizeStart={(_layout, _oldItem, newItem) => {
setResizingWidgetId(newItem.i);
}}
onResizeStop={() => {
setResizingWidgetId(null);
}}
onLayoutChange={handleLayoutChangeWithoutPendingPlaceholder}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
@@ -180,13 +192,13 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
}
>
{gridLayoutItems.map((item) => (
<div key={item.id} data-select-disable="true">
<ReactGridLayoutCardWrapper key={item.id}>
{item.type === 'placeholder' ? (
<WidgetPlaceholder />
) : (
<WidgetRenderer widget={item.widget} />
)}
</div>
</ReactGridLayoutCardWrapper>
))}
</ResponsiveGridLayout>
</StyledGridContainer>
@@ -1,10 +1,19 @@
import { useTheme } from '@emotion/react';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { forwardRef } from 'react';
import { IconRadiusBottomRight } from 'twenty-ui/display';
import {
IconRadiusBottomLeft,
IconRadiusBottomRight,
IconRadiusTopLeft,
IconRadiusTopRight,
} from 'twenty-ui/display';
type WidgetHandleAxis = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
type WidgetHorizontalHandleAxis = 'n' | 's';
type WidgetVerticalHandleAxis = 'e' | 'w';
type PageLayoutGridResizeHandleProps = {
handleAxis?: string;
handleAxis?: WidgetHandleAxis;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
@@ -12,49 +21,218 @@ type PageLayoutGridResizeHandleProps = {
style?: React.CSSProperties;
};
const StyledIcon = styled(IconRadiusBottomRight)`
bottom: ${({ theme }) => theme.spacing(1)};
const StyledBottomRightIcon = styled(IconRadiusBottomRight)`
color: transparent;
cursor: nwse-resize;
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.border.color.strong};
display: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledBottomLeftIcon = styled(IconRadiusBottomLeft)`
color: transparent;
cursor: nesw-resize;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledTopLeftIcon = styled(IconRadiusTopLeft)`
color: transparent;
cursor: nwse-resize;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledTopRightIcon = styled(IconRadiusTopRight)`
color: transparent;
cursor: nesw-resize;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledVerticalHandle = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
height: ${({ theme }) => theme.spacing(5)};
width: ${({ theme }) => theme.icon.stroke.lg}px;
`;
const StyledHorizontalHandle = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
height: ${({ theme }) => theme.icon.stroke.lg}px;
width: ${({ theme }) => theme.spacing(5)};
`;
const StyledHorizontalHandleWrapper = styled.div<{
widgetHandleAxis: WidgetHorizontalHandleAxis;
}>`
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: row-resize;
transform: ${({ widgetHandleAxis }) =>
widgetHandleAxis === 'n' ? 'translateY(-50%)' : 'translateY(50%)'};
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(2)};
:hover {
& > div {
background-color: ${({ theme }) => theme.font.color.tertiary};
}
}
`;
const StyledVerticalHandleWrapper = styled.div<{
widgetHandleAxis: WidgetVerticalHandleAxis;
}>`
cursor: col-resize;
border-radius: ${({ theme }) => theme.border.radius.sm};
transform: ${({ widgetHandleAxis }) =>
widgetHandleAxis === 'w' ? 'translateX(-50%)' : 'translateX(50%)'};
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(2)};
:hover {
& > div {
background-color: ${({ theme }) => theme.font.color.tertiary};
}
}
`;
const StyledResizeHandleWrapper = styled.div<{
widgetHandleAxis?: WidgetHandleAxis;
}>`
position: absolute;
${({ theme, widgetHandleAxis }) => {
if (widgetHandleAxis === 'w') {
return css`
left: ${theme.spacing(2)};
top: 50%;
transform: translateY(-50%);
`;
}
if (widgetHandleAxis === 'e') {
return css`
right: ${theme.spacing(2)};
top: 50%;
transform: translateY(-50%);
`;
}
if (widgetHandleAxis === 'n') {
return css`
top: ${theme.spacing(2)};
left: 50%;
transform: translateX(-50%);
`;
}
if (widgetHandleAxis === 's') {
return css`
bottom: ${theme.spacing(2)};
left: 50%;
transform: translateX(-50%);
`;
}
if (widgetHandleAxis === 'se') {
return css`
bottom: ${theme.spacing(0.5)};
right: ${theme.spacing(0.5)};
`;
}
if (widgetHandleAxis === 'sw') {
return css`
bottom: ${theme.spacing(0.5)};
left: ${theme.spacing(0.5)};
`;
}
if (widgetHandleAxis === 'ne') {
return css`
right: ${theme.spacing(0.5)};
top: ${theme.spacing(0.5)};
`;
}
if (widgetHandleAxis === 'nw') {
return css`
left: ${theme.spacing(0.5)};
top: ${theme.spacing(0.5)};
`;
}
}}
`;
const isVerticalHandle = (
widgetHandleAxis?: WidgetHandleAxis,
): widgetHandleAxis is WidgetVerticalHandleAxis =>
widgetHandleAxis === 'w' || widgetHandleAxis === 'e';
const isHorizontalHandle = (
widgetHandleAxis?: WidgetHandleAxis,
): widgetHandleAxis is WidgetHorizontalHandleAxis =>
widgetHandleAxis === 'n' || widgetHandleAxis === 's';
export const PageLayoutGridResizeHandle = forwardRef<
HTMLDivElement,
PageLayoutGridResizeHandleProps
>((props, ref) => {
const theme = useTheme();
const {
handleAxis = 'se',
onMouseDown,
onMouseUp,
onTouchEnd,
className,
style,
} = props;
>(
(
{
handleAxis: widgetHandleAxis,
onMouseDown,
onMouseUp,
onTouchEnd,
className,
style,
},
ref,
) => {
const theme = useTheme();
if (handleAxis !== 'se') return null;
return (
<div
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
className={className}
style={style}
>
<StyledIcon
size={theme.spacing(4)}
stroke={theme.spacing(1)}
className="widget-card-resize-handle"
/>
</div>
);
});
return (
<StyledResizeHandleWrapper
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
className={className}
style={style}
widgetHandleAxis={widgetHandleAxis}
>
{isVerticalHandle(widgetHandleAxis) && (
<StyledVerticalHandleWrapper widgetHandleAxis={widgetHandleAxis}>
<StyledVerticalHandle />
</StyledVerticalHandleWrapper>
)}
{isHorizontalHandle(widgetHandleAxis) && (
<StyledHorizontalHandleWrapper widgetHandleAxis={widgetHandleAxis}>
<StyledHorizontalHandle />
</StyledHorizontalHandleWrapper>
)}
{widgetHandleAxis === 'ne' && (
<StyledTopRightIcon
size={theme.icon.size.lg}
stroke={theme.icon.stroke.lg}
/>
)}
{widgetHandleAxis === 'nw' && (
<StyledTopLeftIcon
size={theme.icon.size.lg}
stroke={theme.icon.stroke.lg}
/>
)}
{widgetHandleAxis === 'se' && (
<StyledBottomRightIcon
size={theme.icon.size.lg}
stroke={theme.icon.stroke.lg}
/>
)}
{widgetHandleAxis === 'sw' && (
<StyledBottomLeftIcon
size={theme.icon.size.lg}
stroke={theme.icon.stroke.lg}
/>
)}
</StyledResizeHandleWrapper>
);
},
);
@@ -0,0 +1,34 @@
import { forwardRef } from 'react';
export const ReactGridLayoutCardWrapper = forwardRef<
HTMLDivElement,
{
key: string;
children: React.ReactNode;
style?: React.CSSProperties;
className?: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onMouseMove?: React.MouseEventHandler<HTMLDivElement>;
}
>(
(
{ children, key, className, style, onMouseDown, onMouseUp, onMouseMove },
ref,
) => {
return (
<div
ref={ref}
key={key}
data-select-disable="true"
className={className}
style={style}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
>
{children}
</div>
);
},
);
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutResizingWidgetIdComponentState = createComponentState<
string | null
>({
key: 'pageLayoutResizingWidgetIdComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -60,6 +60,7 @@ export const WidgetPlaceholder = () => {
<WidgetCardHeader
isWidgetCardHovered={false}
isInEditMode={isPageLayoutInEditMode}
isResizing={false}
title={t`Add Widget`}
isEmpty
/>
@@ -5,6 +5,7 @@ import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWi
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
@@ -35,6 +36,10 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
pageLayoutDraggingWidgetIdComponentState,
);
const resizingWidgetId = useRecoilComponentValue(
pageLayoutResizingWidgetIdComponentState,
);
const currentlyEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
);
@@ -43,6 +48,8 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const isDragging = draggingWidgetId === widget.id;
const isResizing = resizingWidgetId === widget.id;
const { hasAccess, restriction } = useWidgetPermissions(widget);
const { layoutMode } = usePageLayoutContentContext();
@@ -76,9 +83,10 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
return (
<WidgetCard
isEditing={isEditing}
isDragging={isDragging}
isResizing={isResizing}
layoutMode={layoutMode}
isEditing={isEditing}
pageLayoutType={currentPageLayout.type}
isInPinnedTab={isInPinnedTab}
onClick={isPageLayoutInEditMode ? handleClick : undefined}
@@ -89,6 +97,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
<WidgetCardHeader
isWidgetCardHovered={isHovered}
isInEditMode={isPageLayoutInEditMode}
isResizing={isResizing}
title={widget.title}
onRemove={handleRemove}
forbiddenDisplay={
@@ -15,6 +15,7 @@ export type WidgetCardProps = {
isEditing: boolean;
isDragging: boolean;
isInPinnedTab: boolean;
isResizing?: boolean;
className?: string;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
@@ -28,13 +29,14 @@ const StyledWidgetCard = styled.div<{
isPageLayoutInEditMode: boolean;
isEditing: boolean;
isDragging: boolean;
isResizing: boolean;
}>`
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
height: 100%;
width: 100%;
position: relative;
${({
theme,
@@ -44,6 +46,7 @@ const StyledWidgetCard = styled.div<{
isEditing,
isDragging,
isInPinnedTab,
isResizing,
onClick,
}) => {
if (layoutMode === 'canvas') {
@@ -64,6 +67,7 @@ const StyledWidgetCard = styled.div<{
${isPageLayoutInEditMode &&
!isDragging &&
!isEditing &&
!isResizing &&
css`
&:hover {
border: 1px solid ${theme.border.color.strong};
@@ -101,6 +105,7 @@ const StyledWidgetCard = styled.div<{
${isPageLayoutInEditMode &&
!isDragging &&
!isEditing &&
!isResizing &&
css`
&:hover {
border: 1px solid ${theme.border.color.strong};
@@ -150,6 +155,7 @@ export const WidgetCard = ({
isEditing,
isDragging,
isInPinnedTab,
isResizing = false,
className,
onMouseEnter,
onMouseLeave,
@@ -167,6 +173,7 @@ export const WidgetCard = ({
isEditing={isEditing}
isDragging={isDragging}
isInPinnedTab={isInPinnedTab}
isResizing={isResizing}
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
@@ -17,6 +17,7 @@ export type WidgetCardHeaderProps = {
onRemove?: (e?: React.MouseEvent) => void;
forbiddenDisplay?: ReactNode;
className?: string;
isResizing?: boolean;
};
const StyledWidgetCardHeader = styled.div`
@@ -52,6 +53,7 @@ export const WidgetCardHeader = ({
isWidgetCardHovered = false,
isEmpty = false,
isInEditMode = false,
isResizing = false,
title,
onRemove,
forbiddenDisplay,
@@ -75,24 +77,28 @@ export const WidgetCardHeader = ({
<StyledRightContainer>
{isDefined(forbiddenDisplay) && forbiddenDisplay}
<AnimatePresence>
{!isEmpty && isInEditMode && onRemove && isWidgetCardHovered && (
<StyledIconButtonContainer
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<IconButton
onClick={onRemove}
Icon={IconTrash}
variant="tertiary"
size="small"
/>
</StyledIconButtonContainer>
)}
{!isResizing &&
!isEmpty &&
isInEditMode &&
onRemove &&
isWidgetCardHovered && (
<StyledIconButtonContainer
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<IconButton
onClick={onRemove}
Icon={IconTrash}
variant="tertiary"
size="small"
/>
</StyledIconButtonContainer>
)}
</AnimatePresence>
</StyledRightContainer>
</StyledWidgetCardHeader>
@@ -278,7 +278,10 @@ export {
IconPuzzle,
IconPuzzle2,
IconQuestionMark,
IconRadiusBottomLeft,
IconRadiusBottomRight,
IconRadiusTopLeft,
IconRadiusTopRight,
IconRefresh,
IconRefreshAlert,
IconRefreshDot,
@@ -2839,7 +2839,6 @@ import {
IconMilkshake,
IconMinimize,
IconMinus,
IconMinusVertical,
IconMist,
IconMistOff,
IconMobiledata,
@@ -7023,7 +7022,6 @@ export const ALL_ICONS = {
IconMilkshake,
IconMinimize,
IconMinus,
IconMinusVertical,
IconMist,
IconMistOff,
IconMobiledata,
+3
View File
@@ -343,7 +343,10 @@ export {
IconPuzzle,
IconPuzzle2,
IconQuestionMark,
IconRadiusBottomLeft,
IconRadiusBottomRight,
IconRadiusTopLeft,
IconRadiusTopRight,
IconRefresh,
IconRefreshAlert,
IconRefreshDot,