This commit is contained in:
2025-11-29 02:51:01 +01:00
parent dc6f223fd9
commit 19cabfb1e1
6 changed files with 851 additions and 328 deletions

View File

@@ -1,4 +1,10 @@
import { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
import {
useState,
useRef,
useCallback,
useEffect,
type ReactNode,
} from "react";
import { Box, IconButton, Tooltip, Typography, alpha } from "@mui/material";
import {
ChevronLeft as CollapseLeftIcon,
@@ -6,13 +12,8 @@ import {
DragIndicator as DragIcon,
} from "@mui/icons-material";
export interface PanelConfig {
id: string;
width: number;
collapsed: boolean;
position: "left" | "right";
order: number;
}
// Data transfer type for panel drag and drop
export const PANEL_DRAG_TYPE = "application/x-panel-id";
interface ResizablePanelProps {
/** Unique panel identifier */
@@ -21,32 +22,24 @@ interface ResizablePanelProps {
title: string;
/** Icon shown when panel is collapsed */
icon: ReactNode;
/** Panel position - determines collapse button direction and resize handle position */
/** Panel position - determines collapse button direction */
position: "left" | "right";
/** Current width in pixels */
width: number;
/** Minimum width when expanded */
minWidth?: number;
/** Maximum width when expanded */
maxWidth?: number;
/** Flex value for vertical sizing (default 1) */
flex?: number;
/** Whether the panel is currently collapsed */
collapsed: boolean;
/** Panel width when collapsed (icon strip) */
collapsedWidth?: number;
/** Callback when width changes during resize */
onWidthChange: (width: number) => void;
/** Callback when flex changes during vertical resize */
onFlexChange?: (flex: number) => void;
/** Callback when collapse state changes */
onToggleCollapse: () => void;
/** Panel content */
children: ReactNode;
/** Optional badge content (e.g., count) */
badge?: ReactNode;
/** Enable drag handle for reordering */
draggable?: boolean;
/** Drag start handler */
onDragStart?: (e: React.DragEvent) => void;
/** Drag end handler */
onDragEnd?: (e: React.DragEvent) => void;
/** Whether this is the last panel in the sidebar (no bottom resize handle) */
isLast?: boolean;
}
export default function ResizablePanel({
@@ -54,53 +47,68 @@ export default function ResizablePanel({
title,
icon,
position,
width,
minWidth = 200,
maxWidth = 500,
flex = 1,
collapsed,
collapsedWidth = 44,
onWidthChange,
onFlexChange,
onToggleCollapse,
children,
badge,
draggable = false,
onDragStart,
onDragEnd,
isLast = false,
}: ResizablePanelProps) {
const [isResizing, setIsResizing] = useState(false);
const [isResizingHeight, setIsResizingHeight] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const startWidthRef = useRef(width);
const startYRef = useRef(0);
const startHeightRef = useRef(0);
const startFlexRef = useRef(flex);
const CollapseIcon = position === "left" ? CollapseLeftIcon : CollapseRightIcon;
const CollapseIcon =
position === "left" ? CollapseLeftIcon : CollapseRightIcon;
const ExpandIcon = position === "left" ? CollapseRightIcon : CollapseLeftIcon;
// Handle mouse down on resize handle
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = width;
// Handle drag start - make header draggable
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.dataTransfer.setData(PANEL_DRAG_TYPE, id);
e.dataTransfer.effectAllowed = "move";
setIsDragging(true);
},
[width]
[id],
);
// Handle mouse move during resize
// Handle drag end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Handle mouse down on vertical resize handle
const handleResizeHeightStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
if (!panelRef.current) return;
setIsResizingHeight(true);
startYRef.current = e.clientY;
startHeightRef.current = panelRef.current.offsetHeight;
startFlexRef.current = flex;
},
[flex],
);
// Handle mouse move during vertical resize
useEffect(() => {
if (!isResizing) return;
if (!isResizingHeight || !onFlexChange) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = position === "left"
? e.clientX - startXRef.current
: startXRef.current - e.clientX;
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + delta));
onWidthChange(newWidth);
const deltaY = e.clientY - startYRef.current;
const heightRatio =
(startHeightRef.current + deltaY) / startHeightRef.current;
const newFlex = Math.max(0.2, startFlexRef.current * heightRatio);
onFlexChange(newFlex);
};
const handleMouseUp = () => {
setIsResizing(false);
setIsResizingHeight(false);
};
document.addEventListener("mousemove", handleMouseMove);
@@ -110,34 +118,50 @@ export default function ResizablePanel({
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, position, minWidth, maxWidth, onWidthChange]);
}, [isResizingHeight, onFlexChange]);
// Collapsed state - show icon strip
// Collapsed state - show icon strip (also draggable)
if (collapsed) {
return (
<Box
ref={panelRef}
data-panel-id={id}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sx={{
width: collapsedWidth,
minWidth: collapsedWidth,
height: "100%",
flex: flex,
minHeight: 80,
display: "flex",
flexDirection: "column",
alignItems: "center",
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03),
borderLeft: position === "right" ? 1 : 0,
borderRight: position === "left" ? 1 : 0,
borderBottom: 1,
borderColor: "divider",
py: 1,
flexShrink: 0,
flexShrink: 1,
flexGrow: flex,
cursor: "grab",
opacity: isDragging ? 0.5 : 1,
transition: "opacity 0.2s",
"&:active": {
cursor: "grabbing",
},
}}
>
{/* Expand button */}
<Tooltip title={`Espandi: ${title}`} placement={position === "left" ? "right" : "left"}>
<Tooltip
title={`Espandi: ${title}`}
placement={position === "left" ? "right" : "left"}
>
<IconButton
size="small"
onClick={onToggleCollapse}
onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}}
sx={{
mb: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08),
@@ -151,21 +175,19 @@ export default function ResizablePanel({
</Tooltip>
{/* Panel icon with tooltip */}
<Tooltip title={title} placement={position === "left" ? "right" : "left"}>
<Tooltip
title={`${title} - Trascina per spostare`}
placement={position === "left" ? "right" : "left"}
>
<Box
sx={{
p: 1,
borderRadius: 1,
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0.5,
"&:hover": {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08),
},
}}
onClick={onToggleCollapse}
>
<Box
sx={{
@@ -209,15 +231,15 @@ export default function ResizablePanel({
letterSpacing: 0.5,
fontWeight: 500,
fontSize: "0.7rem",
cursor: "pointer",
"&:hover": {
color: "primary.main",
},
}}
onClick={onToggleCollapse}
>
{title}
</Typography>
{/* Drag indicator at bottom */}
<Box sx={{ mt: "auto", mb: 1, color: "text.disabled" }}>
<DragIcon fontSize="small" />
</Box>
</Box>
);
}
@@ -228,63 +250,26 @@ export default function ResizablePanel({
ref={panelRef}
data-panel-id={id}
sx={{
width,
minWidth,
maxWidth,
height: "100%",
width: "100%",
flex: flex,
minHeight: 120,
display: "flex",
flexDirection: "column",
borderLeft: position === "right" ? 1 : 0,
borderRight: position === "left" ? 1 : 0,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
position: "relative",
flexShrink: 0,
flexShrink: 1,
flexGrow: flex,
opacity: isDragging ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
{/* Resize handle */}
<Box
onMouseDown={handleResizeStart}
sx={{
position: "absolute",
top: 0,
bottom: 0,
[position === "left" ? "right" : "left"]: -3,
width: 6,
cursor: "col-resize",
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:hover": {
"& .resize-indicator": {
opacity: 1,
bgcolor: "primary.main",
},
},
...(isResizing && {
"& .resize-indicator": {
opacity: 1,
bgcolor: "primary.main",
},
}),
}}
>
<Box
className="resize-indicator"
sx={{
width: 3,
height: 40,
borderRadius: 1,
bgcolor: "divider",
opacity: 0.5,
transition: "opacity 0.15s, background-color 0.15s",
}}
/>
</Box>
{/* Header with collapse button */}
{/* Header with collapse button - DRAGGABLE */}
<Box
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sx={{
display: "flex",
alignItems: "center",
@@ -295,27 +280,37 @@ export default function ResizablePanel({
borderColor: "divider",
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03),
minHeight: 40,
cursor: "grab",
userSelect: "none",
"&:active": {
cursor: "grabbing",
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75, minWidth: 0, flex: 1 }}>
{/* Drag handle */}
{draggable && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
minWidth: 0,
flex: 1,
}}
>
{/* Drag handle indicator */}
<Tooltip title="Trascina per spostare">
<Box
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
sx={{
cursor: "grab",
color: "text.disabled",
display: "flex",
"&:hover": { color: "text.secondary" },
"&:active": { cursor: "grabbing" },
}}
>
<DragIcon fontSize="small" />
</Box>
)}
<Box sx={{ color: "primary.main", display: "flex", flexShrink: 0 }}>{icon}</Box>
</Tooltip>
<Box sx={{ color: "primary.main", display: "flex", flexShrink: 0 }}>
{icon}
</Box>
<Typography
variant="subtitle2"
fontWeight={600}
@@ -348,13 +343,20 @@ export default function ResizablePanel({
)}
</Box>
<Tooltip title="Comprimi">
<IconButton size="small" onClick={onToggleCollapse} sx={{ flexShrink: 0 }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}}
sx={{ flexShrink: 0 }}
>
<CollapseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Content - no internal scrollbar, content must handle its own overflow */}
{/* Content */}
<Box
sx={{
flex: 1,
@@ -365,6 +367,49 @@ export default function ResizablePanel({
>
{children}
</Box>
{/* Vertical resize handle (only show if not last panel and onFlexChange is provided) */}
{!isLast && onFlexChange && (
<Box
onMouseDown={handleResizeHeightStart}
sx={{
position: "absolute",
bottom: -4,
left: 0,
right: 0,
height: 8,
cursor: "row-resize",
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:hover": {
"& .resize-indicator-v": {
opacity: 1,
bgcolor: "primary.main",
},
},
...(isResizingHeight && {
"& .resize-indicator-v": {
opacity: 1,
bgcolor: "primary.main",
},
}),
}}
>
<Box
className="resize-indicator-v"
sx={{
width: 50,
height: 4,
borderRadius: 1,
bgcolor: "divider",
opacity: 0.7,
transition: "opacity 0.15s, background-color 0.15s",
}}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,324 @@
import {
useState,
useCallback,
useEffect,
useRef,
type ReactNode,
} from "react";
import { Box, alpha } from "@mui/material";
import { PANEL_DRAG_TYPE } from "./ResizablePanel";
interface SidebarDropZoneProps {
/** Sidebar position */
position: "left" | "right";
/** Sidebar width in pixels */
width: number;
/** Minimum width */
minWidth?: number;
/** Maximum width */
maxWidth?: number;
/** Callback when width changes */
onWidthChange: (width: number) => void;
/** Children (panels) */
children: ReactNode;
/** Callback when a panel is dropped */
onPanelDrop: (
panelId: string,
targetPosition: "left" | "right",
targetIndex: number,
) => void;
/** Current panel IDs in this sidebar (for calculating drop index) */
panelIds: string[];
}
export default function SidebarDropZone({
position,
width,
minWidth = 180,
maxWidth = 500,
onWidthChange,
children,
onPanelDrop,
panelIds,
}: SidebarDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [isResizing, setIsResizing] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(width);
// Handle horizontal resize
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = width;
},
[width],
);
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
const delta =
position === "left"
? e.clientX - startXRef.current
: startXRef.current - e.clientX;
const newWidth = Math.min(
maxWidth,
Math.max(minWidth, startWidthRef.current + delta),
);
onWidthChange(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, position, minWidth, maxWidth, onWidthChange]);
// Handle drag over
const handleDragOver = useCallback(
(e: React.DragEvent) => {
// Only accept panel drags
if (!e.dataTransfer.types.includes(PANEL_DRAG_TYPE)) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDragOver(true);
// Calculate drop index based on mouse position
const container = e.currentTarget as HTMLElement;
const rect = container.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
// Find which panel slot the mouse is over
const panelElements = container.querySelectorAll("[data-panel-id]");
let newDropIndex = panelIds.length; // Default to end
panelElements.forEach((el, index) => {
const panelRect = el.getBoundingClientRect();
const panelMiddle = panelRect.top + panelRect.height / 2 - rect.top;
if (mouseY < panelMiddle) {
newDropIndex = Math.min(newDropIndex, index);
}
});
setDropIndex(newDropIndex);
},
[panelIds.length],
);
// Handle drag leave
const handleDragLeave = useCallback((e: React.DragEvent) => {
// Only reset if leaving the container entirely
const relatedTarget = e.relatedTarget as HTMLElement;
if (!e.currentTarget.contains(relatedTarget)) {
setIsDragOver(false);
setDropIndex(null);
}
}, []);
// Handle drop
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const panelId = e.dataTransfer.getData(PANEL_DRAG_TYPE);
console.log(
"[SidebarDropZone] Drop:",
panelId,
"position:",
position,
"dropIndex:",
dropIndex,
);
if (!panelId) return;
// Calculate final drop index
const finalIndex = dropIndex ?? panelIds.length;
// If dropping in same sidebar, adjust index if needed
const currentIndex = panelIds.indexOf(panelId);
let adjustedIndex = finalIndex;
if (currentIndex !== -1 && currentIndex < finalIndex) {
// If moving down in same sidebar, subtract 1 because the panel will be removed first
adjustedIndex = finalIndex - 1;
}
onPanelDrop(panelId, position, adjustedIndex);
setDropIndex(null);
},
[dropIndex, panelIds, position, onPanelDrop],
);
// Calculate drop indicator position
const getDropIndicatorTop = useCallback(() => {
if (dropIndex === null) return 0;
// We need to find the panel elements in the DOM
const panelElements = document.querySelectorAll(
`[data-sidebar="${position}"] [data-panel-id]`,
);
if (dropIndex === 0) return 0;
if (dropIndex >= panelElements.length) {
// Drop at end - position after last panel
const lastPanel = panelElements[panelElements.length - 1];
if (lastPanel) {
const container = lastPanel.parentElement;
if (container) {
const rect = lastPanel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return rect.bottom - containerRect.top;
}
}
return "auto";
}
const targetPanel = panelElements[dropIndex];
if (targetPanel) {
const container = targetPanel.parentElement;
if (container) {
const rect = targetPanel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return rect.top - containerRect.top;
}
}
return 0;
}, [dropIndex, position]);
const hasNoPanels = panelIds.length === 0;
return (
<Box
data-sidebar={position}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
width: width,
minWidth: hasNoPanels ? 60 : minWidth,
maxWidth: maxWidth,
position: "relative",
flexShrink: 0,
// Visual feedback during drag
...(isDragOver && {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.05),
}),
transition: "background-color 0.2s",
}}
>
{/* Horizontal resize handle */}
<Box
onMouseDown={handleResizeStart}
sx={{
position: "absolute",
top: 0,
bottom: 0,
[position === "left" ? "right" : "left"]: -4,
width: 8,
cursor: "col-resize",
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:hover": {
"& .resize-indicator": {
opacity: 1,
bgcolor: "primary.main",
},
},
...(isResizing && {
"& .resize-indicator": {
opacity: 1,
bgcolor: "primary.main",
},
}),
}}
>
<Box
className="resize-indicator"
sx={{
width: 4,
height: 50,
borderRadius: 1,
bgcolor: "divider",
opacity: 0.7,
transition: "opacity 0.15s, background-color 0.15s",
}}
/>
</Box>
{/* Drop indicator line */}
{isDragOver && dropIndex !== null && !hasNoPanels && (
<Box
sx={{
position: "absolute",
left: 0,
right: 0,
height: 3,
bgcolor: "primary.main",
borderRadius: 1,
zIndex: 100,
top: getDropIndicatorTop(),
pointerEvents: "none",
// Box shadow for glow effect
boxShadow: (theme) =>
`0 0 8px 2px ${alpha(theme.palette.primary.main, 0.4)}`,
}}
/>
)}
{/* Panels */}
{children}
{/* Empty state drop zone (when sidebar has no panels) */}
{hasNoPanels && (
<Box
sx={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: isDragOver ? 2 : 1,
borderColor: isDragOver ? "primary.main" : "divider",
borderStyle: "dashed",
borderRadius: 1,
m: 1,
p: 2,
color: "text.secondary",
fontSize: "0.75rem",
textAlign: "center",
transition: "all 0.2s",
bgcolor: isDragOver
? (theme) => alpha(theme.palette.primary.main, 0.1)
: "transparent",
}}
>
Trascina qui
</Box>
)}
</Box>
);
}

View File

@@ -3,50 +3,103 @@ import { useLocalStorage } from "./useLocalStorage";
export interface PanelState {
id: string;
width: number;
/** Flex value for vertical sizing (1 = equal share, 2 = double share, etc.) */
flex: number;
collapsed: boolean;
position: "left" | "right";
order: number;
}
export interface SidebarWidths {
left: number;
right: number;
}
export interface PanelLayoutConfig {
panels: PanelState[];
sidebarWidths: SidebarWidths;
version: number;
}
const STORAGE_KEY = "apollinare-report-editor-panels";
const CONFIG_VERSION = 1;
const CONFIG_VERSION = 3; // Bumped version for sidebar widths
// Default sidebar widths
const defaultSidebarWidths: SidebarWidths = {
left: 250,
right: 300,
};
// Default panel configuration
const defaultConfig: PanelLayoutConfig = {
version: CONFIG_VERSION,
sidebarWidths: defaultSidebarWidths,
panels: [
{ id: "pages", width: 220, collapsed: false, position: "left", order: 0 },
{ id: "data", width: 280, collapsed: false, position: "right", order: 0 },
{ id: "properties", width: 280, collapsed: false, position: "right", order: 1 },
{
id: "pages",
flex: 1,
collapsed: false,
position: "left",
order: 0,
},
{
id: "data",
flex: 1,
collapsed: false,
position: "right",
order: 0,
},
{
id: "properties",
flex: 1,
collapsed: false,
position: "right",
order: 1,
},
],
};
export function usePanelLayout() {
const [config, setConfig] = useLocalStorage<PanelLayoutConfig>(
const [config, setConfig, clearConfig] = useLocalStorage<PanelLayoutConfig>(
STORAGE_KEY,
defaultConfig
defaultConfig,
);
// Ensure config is up to date (handle version migrations)
const normalizedConfig = useMemo(() => {
if (!config || config.version !== CONFIG_VERSION) {
if (!config) {
return defaultConfig;
}
return config;
}, [config]);
// If version mismatch, migrate or reset
if (config.version !== CONFIG_VERSION) {
console.log(
"[usePanelLayout] Version mismatch, resetting to default config",
);
// Clear old config and return default
clearConfig();
return defaultConfig;
}
// Ensure all panels have flex property
const migratedPanels = config.panels.map((p) => ({
...p,
flex: p.flex ?? 1,
}));
return {
...config,
sidebarWidths: config.sidebarWidths ?? defaultSidebarWidths,
panels: migratedPanels,
};
}, [config, clearConfig]);
// Get panel state by id
const getPanelState = useCallback(
(panelId: string): PanelState | undefined => {
return normalizedConfig.panels.find((p) => p.id === panelId);
},
[normalizedConfig.panels]
[normalizedConfig.panels],
);
// Update a single panel's state
@@ -55,11 +108,11 @@ export function usePanelLayout() {
setConfig((prev) => ({
...prev,
panels: prev.panels.map((p) =>
p.id === panelId ? { ...p, ...updates } : p
p.id === panelId ? { ...p, ...updates } : p,
),
}));
},
[setConfig]
[setConfig],
);
// Toggle panel collapse state
@@ -68,48 +121,110 @@ export function usePanelLayout() {
setConfig((prev) => ({
...prev,
panels: prev.panels.map((p) =>
p.id === panelId ? { ...p, collapsed: !p.collapsed } : p
p.id === panelId ? { ...p, collapsed: !p.collapsed } : p,
),
}));
},
[setConfig]
[setConfig],
);
// Update panel width
const setPanelWidth = useCallback(
(panelId: string, width: number) => {
// Update sidebar width
const setSidebarWidth = useCallback(
(position: "left" | "right", width: number) => {
setConfig((prev) => ({
...prev,
sidebarWidths: {
...prev.sidebarWidths,
[position]: Math.max(180, Math.min(500, width)),
},
}));
},
[setConfig],
);
// Update panel flex value
const setPanelFlex = useCallback(
(panelId: string, flex: number) => {
setConfig((prev) => ({
...prev,
panels: prev.panels.map((p) =>
p.id === panelId ? { ...p, width } : p
p.id === panelId ? { ...p, flex: Math.max(0.2, flex) } : p,
),
}));
},
[setConfig]
[setConfig],
);
// Move panel to a different position (left/right sidebar)
// Auto-redistributes flex values so all panels in the sidebar share space equally
const movePanelToPosition = useCallback(
(panelId: string, newPosition: "left" | "right", newOrder?: number) => {
setConfig((prev) => {
const movingPanel = prev.panels.find((p) => p.id === panelId);
if (!movingPanel) return prev;
const isMovingToNewSidebar = movingPanel.position !== newPosition;
// Get panels in the target position (excluding the moving panel)
const panelsInNewPosition = prev.panels.filter(
(p) => p.position === newPosition && p.id !== panelId
(p) => p.position === newPosition && p.id !== panelId,
);
// Calculate new order
const maxOrder =
panelsInNewPosition.length > 0
? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1
: 0;
const finalOrder = newOrder ?? maxOrder;
// All panels get flex: 1 for equal distribution
const equalFlex = 1;
// Get panels in the old position (excluding the moving panel) for rebalancing
const panelsInOldPosition = prev.panels.filter(
(p) => p.position === movingPanel.position && p.id !== panelId,
);
const maxOrder = panelsInNewPosition.length > 0
? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1
: 0;
return {
...prev,
panels: prev.panels.map((p) =>
p.id === panelId
? { ...p, position: newPosition, order: newOrder ?? maxOrder }
: p
),
panels: prev.panels.map((p) => {
// The panel being moved
if (p.id === panelId) {
return {
...p,
position: newPosition,
order: finalOrder,
flex: equalFlex,
};
}
// Panels in the target sidebar: reset to equal flex
if (p.position === newPosition && isMovingToNewSidebar) {
return { ...p, flex: equalFlex };
}
// Panels in the old sidebar (after panel leaves): reset to equal flex
if (
isMovingToNewSidebar &&
p.position === movingPanel.position &&
panelsInOldPosition.length > 0
) {
return { ...p, flex: equalFlex };
}
// Adjust order for panels in target position if inserting at specific index
if (p.position === newPosition && newOrder !== undefined) {
if (p.order >= newOrder) {
return { ...p, order: p.order + 1 };
}
}
return p;
}),
};
});
},
[setConfig]
[setConfig],
);
// Reorder panels within a sidebar
@@ -124,7 +239,7 @@ export function usePanelLayout() {
}),
}));
},
[setConfig]
[setConfig],
);
// Get panels for a specific position, sorted by order
@@ -134,7 +249,15 @@ export function usePanelLayout() {
.filter((p) => p.position === position)
.sort((a, b) => a.order - b.order);
},
[normalizedConfig.panels]
[normalizedConfig.panels],
);
// Get sidebar width
const getSidebarWidth = useCallback(
(position: "left" | "right"): number => {
return normalizedConfig.sidebarWidths[position];
},
[normalizedConfig.sidebarWidths],
);
// Reset to default configuration
@@ -147,7 +270,9 @@ export function usePanelLayout() {
getPanelState,
updatePanel,
togglePanelCollapse,
setPanelWidth,
setSidebarWidth,
getSidebarWidth,
setPanelFlex,
movePanelToPosition,
reorderPanels,
getPanelsForPosition,

View File

@@ -64,6 +64,7 @@ import ImageUploadDialog, {
} from "../components/reportEditor/ImageUploadDialog";
import PageNavigator from "../components/reportEditor/PageNavigator";
import ResizablePanel from "../components/reportEditor/ResizablePanel";
import SidebarDropZone from "../components/reportEditor/SidebarDropZone";
import {
reportTemplateService,
reportFontService,
@@ -149,6 +150,18 @@ export default function ReportEditorPage() {
// Panel layout configuration (persisted to localStorage)
const panelLayout = usePanelLayout();
// Handle panel drag and drop between sidebars
const handlePanelDrop = useCallback(
(
panelId: string,
targetPosition: "left" | "right",
targetIndex: number,
) => {
panelLayout.movePanelToPosition(panelId, targetPosition, targetIndex);
},
[panelLayout],
);
// UI state
const [saveDialog, setSaveDialog] = useState(false);
const [previewDialog, setPreviewDialog] = useState(false);
@@ -1901,88 +1914,96 @@ export default function ReportEditorPage() {
{/* Main Editor Area */}
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Left Sidebar - Panels on left side */}
{!isMobile && (
<Box sx={{ display: "flex", height: "100%" }}>
{panelLayout.getPanelsForPosition("left").map((panelState) => {
if (panelState.id === "pages") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Pagine"
icon={<LayersIcon />}
position="left"
width={panelState.width}
minWidth={180}
maxWidth={350}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={template.pages.length}
>
{renderPageNavigator(true)}
</ResizablePanel>
);
}
if (panelState.id === "data") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Campi Dati"
icon={<DataIcon />}
position="left"
width={panelState.width}
minWidth={220}
maxWidth={400}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={
selectedDatasets.length > 0
? selectedDatasets.length
: undefined
}
>
{renderDataBindingPanel(true)}
</ResizablePanel>
);
}
if (panelState.id === "properties") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Proprietà"
icon={<SettingsIcon />}
position="left"
width={panelState.width}
minWidth={220}
maxWidth={400}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
>
{renderPropertiesPanel(true)}
</ResizablePanel>
);
}
return null;
})}
</Box>
)}
{!isMobile &&
(() => {
const leftPanels = panelLayout.getPanelsForPosition("left");
return (
<SidebarDropZone
position="left"
width={panelLayout.getSidebarWidth("left")}
onWidthChange={(w) => panelLayout.setSidebarWidth("left", w)}
onPanelDrop={handlePanelDrop}
panelIds={leftPanels.map((p) => p.id)}
>
{leftPanels.map((panelState, index) => {
const isLast = index === leftPanels.length - 1;
if (panelState.id === "pages") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Pagine"
icon={<LayersIcon />}
position="left"
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={template.pages.length}
isLast={isLast}
>
{renderPageNavigator(true)}
</ResizablePanel>
);
}
if (panelState.id === "data") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Campi Dati"
icon={<DataIcon />}
position="left"
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={
selectedDatasets.length > 0
? selectedDatasets.length
: undefined
}
isLast={isLast}
>
{renderDataBindingPanel(true)}
</ResizablePanel>
);
}
if (panelState.id === "properties") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Proprietà"
icon={<SettingsIcon />}
position="left"
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
isLast={isLast}
>
{renderPropertiesPanel(true)}
</ResizablePanel>
);
}
return null;
})}
</SidebarDropZone>
);
})()}
{/* Center Area - Canvas Container (flex: 1 to take remaining space, centers the canvas) */}
<Box
@@ -2034,88 +2055,96 @@ export default function ReportEditorPage() {
</Box>
{/* Right Sidebar - Panels on right side */}
{!isMobile && (
<Box sx={{ display: "flex", height: "100%" }}>
{panelLayout.getPanelsForPosition("right").map((panelState) => {
if (panelState.id === "pages") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Pagine"
icon={<LayersIcon />}
position="right"
width={panelState.width}
minWidth={180}
maxWidth={350}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={template.pages.length}
>
{renderPageNavigator(true)}
</ResizablePanel>
);
}
if (panelState.id === "data") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Campi Dati"
icon={<DataIcon />}
position="right"
width={panelState.width}
minWidth={220}
maxWidth={400}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={
selectedDatasets.length > 0
? selectedDatasets.length
: undefined
}
>
{renderDataBindingPanel(true)}
</ResizablePanel>
);
}
if (panelState.id === "properties") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Proprietà"
icon={<SettingsIcon />}
position="right"
width={panelState.width}
minWidth={220}
maxWidth={400}
collapsed={panelState.collapsed}
onWidthChange={(w) =>
panelLayout.setPanelWidth(panelState.id, w)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
>
{renderPropertiesPanel(true)}
</ResizablePanel>
);
}
return null;
})}
</Box>
)}
{!isMobile &&
(() => {
const rightPanels = panelLayout.getPanelsForPosition("right");
return (
<SidebarDropZone
position="right"
width={panelLayout.getSidebarWidth("right")}
onWidthChange={(w) => panelLayout.setSidebarWidth("right", w)}
onPanelDrop={handlePanelDrop}
panelIds={rightPanels.map((p) => p.id)}
>
{rightPanels.map((panelState, index) => {
const isLast = index === rightPanels.length - 1;
if (panelState.id === "pages") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Pagine"
icon={<LayersIcon />}
position={panelState.position}
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={template.pages.length}
isLast={isLast}
>
{renderPageNavigator(true)}
</ResizablePanel>
);
}
if (panelState.id === "data") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Campi Dati"
icon={<DataIcon />}
position={panelState.position}
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
badge={
selectedDatasets.length > 0
? selectedDatasets.length
: undefined
}
isLast={isLast}
>
{renderDataBindingPanel(true)}
</ResizablePanel>
);
}
if (panelState.id === "properties") {
return (
<ResizablePanel
key={panelState.id}
id={panelState.id}
title="Proprietà"
icon={<SettingsIcon />}
position={panelState.position}
flex={panelState.flex}
collapsed={panelState.collapsed}
onFlexChange={(f) =>
panelLayout.setPanelFlex(panelState.id, f)
}
onToggleCollapse={() =>
panelLayout.togglePanelCollapse(panelState.id)
}
isLast={isLast}
>
{renderPropertiesPanel(true)}
</ResizablePanel>
);
}
return null;
})}
</SidebarDropZone>
);
})()}
</Box>
{/* Mobile Bottom Navigation */}

Binary file not shown.

Binary file not shown.