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 { Box, IconButton, Tooltip, Typography, alpha } from "@mui/material";
import { import {
ChevronLeft as CollapseLeftIcon, ChevronLeft as CollapseLeftIcon,
@@ -6,13 +12,8 @@ import {
DragIndicator as DragIcon, DragIndicator as DragIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
export interface PanelConfig { // Data transfer type for panel drag and drop
id: string; export const PANEL_DRAG_TYPE = "application/x-panel-id";
width: number;
collapsed: boolean;
position: "left" | "right";
order: number;
}
interface ResizablePanelProps { interface ResizablePanelProps {
/** Unique panel identifier */ /** Unique panel identifier */
@@ -21,32 +22,24 @@ interface ResizablePanelProps {
title: string; title: string;
/** Icon shown when panel is collapsed */ /** Icon shown when panel is collapsed */
icon: ReactNode; icon: ReactNode;
/** Panel position - determines collapse button direction and resize handle position */ /** Panel position - determines collapse button direction */
position: "left" | "right"; position: "left" | "right";
/** Current width in pixels */ /** Flex value for vertical sizing (default 1) */
width: number; flex?: number;
/** Minimum width when expanded */
minWidth?: number;
/** Maximum width when expanded */
maxWidth?: number;
/** Whether the panel is currently collapsed */ /** Whether the panel is currently collapsed */
collapsed: boolean; collapsed: boolean;
/** Panel width when collapsed (icon strip) */ /** Panel width when collapsed (icon strip) */
collapsedWidth?: number; collapsedWidth?: number;
/** Callback when width changes during resize */ /** Callback when flex changes during vertical resize */
onWidthChange: (width: number) => void; onFlexChange?: (flex: number) => void;
/** Callback when collapse state changes */ /** Callback when collapse state changes */
onToggleCollapse: () => void; onToggleCollapse: () => void;
/** Panel content */ /** Panel content */
children: ReactNode; children: ReactNode;
/** Optional badge content (e.g., count) */ /** Optional badge content (e.g., count) */
badge?: ReactNode; badge?: ReactNode;
/** Enable drag handle for reordering */ /** Whether this is the last panel in the sidebar (no bottom resize handle) */
draggable?: boolean; isLast?: boolean;
/** Drag start handler */
onDragStart?: (e: React.DragEvent) => void;
/** Drag end handler */
onDragEnd?: (e: React.DragEvent) => void;
} }
export default function ResizablePanel({ export default function ResizablePanel({
@@ -54,53 +47,68 @@ export default function ResizablePanel({
title, title,
icon, icon,
position, position,
width, flex = 1,
minWidth = 200,
maxWidth = 500,
collapsed, collapsed,
collapsedWidth = 44, collapsedWidth = 44,
onWidthChange, onFlexChange,
onToggleCollapse, onToggleCollapse,
children, children,
badge, badge,
draggable = false, isLast = false,
onDragStart,
onDragEnd,
}: ResizablePanelProps) { }: ResizablePanelProps) {
const [isResizing, setIsResizing] = useState(false); const [isResizingHeight, setIsResizingHeight] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0); const startYRef = useRef(0);
const startWidthRef = useRef(width); 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; const ExpandIcon = position === "left" ? CollapseRightIcon : CollapseLeftIcon;
// Handle mouse down on resize handle // Handle drag start - make header draggable
const handleResizeStart = useCallback( const handleDragStart = useCallback(
(e: React.MouseEvent) => { (e: React.DragEvent) => {
e.preventDefault(); e.dataTransfer.setData(PANEL_DRAG_TYPE, id);
setIsResizing(true); e.dataTransfer.effectAllowed = "move";
startXRef.current = e.clientX; setIsDragging(true);
startWidthRef.current = width;
}, },
[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(() => { useEffect(() => {
if (!isResizing) return; if (!isResizingHeight || !onFlexChange) return;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const delta = position === "left" const deltaY = e.clientY - startYRef.current;
? e.clientX - startXRef.current const heightRatio =
: startXRef.current - e.clientX; (startHeightRef.current + deltaY) / startHeightRef.current;
const newFlex = Math.max(0.2, startFlexRef.current * heightRatio);
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + delta)); onFlexChange(newFlex);
onWidthChange(newWidth);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
setIsResizing(false); setIsResizingHeight(false);
}; };
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
@@ -110,34 +118,50 @@ export default function ResizablePanel({
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); 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) { if (collapsed) {
return ( return (
<Box <Box
ref={panelRef} ref={panelRef}
data-panel-id={id} data-panel-id={id}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sx={{ sx={{
width: collapsedWidth, width: collapsedWidth,
minWidth: collapsedWidth, minWidth: collapsedWidth,
height: "100%", flex: flex,
minHeight: 80,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03), bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03),
borderLeft: position === "right" ? 1 : 0, borderBottom: 1,
borderRight: position === "left" ? 1 : 0,
borderColor: "divider", borderColor: "divider",
py: 1, py: 1,
flexShrink: 0, flexShrink: 1,
flexGrow: flex,
cursor: "grab",
opacity: isDragging ? 0.5 : 1,
transition: "opacity 0.2s",
"&:active": {
cursor: "grabbing",
},
}} }}
> >
{/* Expand button */} {/* Expand button */}
<Tooltip title={`Espandi: ${title}`} placement={position === "left" ? "right" : "left"}> <Tooltip
title={`Espandi: ${title}`}
placement={position === "left" ? "right" : "left"}
>
<IconButton <IconButton
size="small" size="small"
onClick={onToggleCollapse} onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}}
sx={{ sx={{
mb: 1, mb: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08), bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08),
@@ -151,21 +175,19 @@ export default function ResizablePanel({
</Tooltip> </Tooltip>
{/* Panel icon with 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 <Box
sx={{ sx={{
p: 1, p: 1,
borderRadius: 1, borderRadius: 1,
cursor: "pointer",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: 0.5, gap: 0.5,
"&:hover": {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08),
},
}} }}
onClick={onToggleCollapse}
> >
<Box <Box
sx={{ sx={{
@@ -209,15 +231,15 @@ export default function ResizablePanel({
letterSpacing: 0.5, letterSpacing: 0.5,
fontWeight: 500, fontWeight: 500,
fontSize: "0.7rem", fontSize: "0.7rem",
cursor: "pointer",
"&:hover": {
color: "primary.main",
},
}} }}
onClick={onToggleCollapse}
> >
{title} {title}
</Typography> </Typography>
{/* Drag indicator at bottom */}
<Box sx={{ mt: "auto", mb: 1, color: "text.disabled" }}>
<DragIcon fontSize="small" />
</Box>
</Box> </Box>
); );
} }
@@ -228,63 +250,26 @@ export default function ResizablePanel({
ref={panelRef} ref={panelRef}
data-panel-id={id} data-panel-id={id}
sx={{ sx={{
width, width: "100%",
minWidth, flex: flex,
maxWidth, minHeight: 120,
height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
borderLeft: position === "right" ? 1 : 0, borderBottom: 1,
borderRight: position === "left" ? 1 : 0,
borderColor: "divider", borderColor: "divider",
bgcolor: "background.paper", bgcolor: "background.paper",
position: "relative", position: "relative",
flexShrink: 0, flexShrink: 1,
flexGrow: flex,
opacity: isDragging ? 0.5 : 1,
transition: "opacity 0.2s",
}} }}
> >
{/* Resize handle */} {/* Header with collapse button - DRAGGABLE */}
<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 */}
<Box <Box
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -295,27 +280,37 @@ export default function ResizablePanel({
borderColor: "divider", borderColor: "divider",
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03), bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03),
minHeight: 40, minHeight: 40,
cursor: "grab",
userSelect: "none",
"&:active": {
cursor: "grabbing",
},
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75, minWidth: 0, flex: 1 }}> <Box
{/* Drag handle */} sx={{
{draggable && ( display: "flex",
alignItems: "center",
gap: 0.75,
minWidth: 0,
flex: 1,
}}
>
{/* Drag handle indicator */}
<Tooltip title="Trascina per spostare">
<Box <Box
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
sx={{ sx={{
cursor: "grab",
color: "text.disabled", color: "text.disabled",
display: "flex", display: "flex",
"&:hover": { color: "text.secondary" }, "&:hover": { color: "text.secondary" },
"&:active": { cursor: "grabbing" },
}} }}
> >
<DragIcon fontSize="small" /> <DragIcon fontSize="small" />
</Box> </Box>
)} </Tooltip>
<Box sx={{ color: "primary.main", display: "flex", flexShrink: 0 }}>{icon}</Box> <Box sx={{ color: "primary.main", display: "flex", flexShrink: 0 }}>
{icon}
</Box>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
fontWeight={600} fontWeight={600}
@@ -348,13 +343,20 @@ export default function ResizablePanel({
)} )}
</Box> </Box>
<Tooltip title="Comprimi"> <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" /> <CollapseIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
{/* Content - no internal scrollbar, content must handle its own overflow */} {/* Content */}
<Box <Box
sx={{ sx={{
flex: 1, flex: 1,
@@ -365,6 +367,49 @@ export default function ResizablePanel({
> >
{children} {children}
</Box> </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> </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 { export interface PanelState {
id: string; id: string;
width: number; /** Flex value for vertical sizing (1 = equal share, 2 = double share, etc.) */
flex: number;
collapsed: boolean; collapsed: boolean;
position: "left" | "right"; position: "left" | "right";
order: number; order: number;
} }
export interface SidebarWidths {
left: number;
right: number;
}
export interface PanelLayoutConfig { export interface PanelLayoutConfig {
panels: PanelState[]; panels: PanelState[];
sidebarWidths: SidebarWidths;
version: number; version: number;
} }
const STORAGE_KEY = "apollinare-report-editor-panels"; 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 // Default panel configuration
const defaultConfig: PanelLayoutConfig = { const defaultConfig: PanelLayoutConfig = {
version: CONFIG_VERSION, version: CONFIG_VERSION,
sidebarWidths: defaultSidebarWidths,
panels: [ panels: [
{ id: "pages", width: 220, collapsed: false, position: "left", order: 0 }, {
{ id: "data", width: 280, collapsed: false, position: "right", order: 0 }, id: "pages",
{ id: "properties", width: 280, collapsed: false, position: "right", order: 1 }, 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() { export function usePanelLayout() {
const [config, setConfig] = useLocalStorage<PanelLayoutConfig>( const [config, setConfig, clearConfig] = useLocalStorage<PanelLayoutConfig>(
STORAGE_KEY, STORAGE_KEY,
defaultConfig defaultConfig,
); );
// Ensure config is up to date (handle version migrations) // Ensure config is up to date (handle version migrations)
const normalizedConfig = useMemo(() => { const normalizedConfig = useMemo(() => {
if (!config || config.version !== CONFIG_VERSION) { if (!config) {
return defaultConfig; 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 // Get panel state by id
const getPanelState = useCallback( const getPanelState = useCallback(
(panelId: string): PanelState | undefined => { (panelId: string): PanelState | undefined => {
return normalizedConfig.panels.find((p) => p.id === panelId); return normalizedConfig.panels.find((p) => p.id === panelId);
}, },
[normalizedConfig.panels] [normalizedConfig.panels],
); );
// Update a single panel's state // Update a single panel's state
@@ -55,11 +108,11 @@ export function usePanelLayout() {
setConfig((prev) => ({ setConfig((prev) => ({
...prev, ...prev,
panels: prev.panels.map((p) => panels: prev.panels.map((p) =>
p.id === panelId ? { ...p, ...updates } : p p.id === panelId ? { ...p, ...updates } : p,
), ),
})); }));
}, },
[setConfig] [setConfig],
); );
// Toggle panel collapse state // Toggle panel collapse state
@@ -68,48 +121,110 @@ export function usePanelLayout() {
setConfig((prev) => ({ setConfig((prev) => ({
...prev, ...prev,
panels: prev.panels.map((p) => 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 // Update sidebar width
const setPanelWidth = useCallback( const setSidebarWidth = useCallback(
(panelId: string, width: number) => { (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) => ({ setConfig((prev) => ({
...prev, ...prev,
panels: prev.panels.map((p) => 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) // 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( const movePanelToPosition = useCallback(
(panelId: string, newPosition: "left" | "right", newOrder?: number) => { (panelId: string, newPosition: "left" | "right", newOrder?: number) => {
setConfig((prev) => { 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( 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 { return {
...prev, ...prev,
panels: prev.panels.map((p) => panels: prev.panels.map((p) => {
p.id === panelId // The panel being moved
? { ...p, position: newPosition, order: newOrder ?? maxOrder } if (p.id === panelId) {
: p 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 // Reorder panels within a sidebar
@@ -124,7 +239,7 @@ export function usePanelLayout() {
}), }),
})); }));
}, },
[setConfig] [setConfig],
); );
// Get panels for a specific position, sorted by order // Get panels for a specific position, sorted by order
@@ -134,7 +249,15 @@ export function usePanelLayout() {
.filter((p) => p.position === position) .filter((p) => p.position === position)
.sort((a, b) => a.order - b.order); .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 // Reset to default configuration
@@ -147,7 +270,9 @@ export function usePanelLayout() {
getPanelState, getPanelState,
updatePanel, updatePanel,
togglePanelCollapse, togglePanelCollapse,
setPanelWidth, setSidebarWidth,
getSidebarWidth,
setPanelFlex,
movePanelToPosition, movePanelToPosition,
reorderPanels, reorderPanels,
getPanelsForPosition, getPanelsForPosition,

View File

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

Binary file not shown.

Binary file not shown.