-
This commit is contained in:
@@ -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 }}>
|
|
||||||
{/* Drag handle */}
|
|
||||||
{draggable && (
|
|
||||||
<Box
|
<Box
|
||||||
draggable
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
sx={{
|
sx={{
|
||||||
cursor: "grab",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle indicator */}
|
||||||
|
<Tooltip title="Trascina per spostare">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
324
frontend/src/components/reportEditor/SidebarDropZone.tsx
Normal file
324
frontend/src/components/reportEditor/SidebarDropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
const maxOrder = panelsInNewPosition.length > 0
|
|
||||||
|
// Calculate new order
|
||||||
|
const maxOrder =
|
||||||
|
panelsInNewPosition.length > 0
|
||||||
? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1
|
? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1
|
||||||
: 0;
|
: 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,
|
||||||
|
);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,9 +1914,19 @@ 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");
|
||||||
|
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") {
|
if (panelState.id === "pages") {
|
||||||
return (
|
return (
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@@ -1912,17 +1935,16 @@ export default function ReportEditorPage() {
|
|||||||
title="Pagine"
|
title="Pagine"
|
||||||
icon={<LayersIcon />}
|
icon={<LayersIcon />}
|
||||||
position="left"
|
position="left"
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={180}
|
|
||||||
maxWidth={350}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
}
|
}
|
||||||
badge={template.pages.length}
|
badge={template.pages.length}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderPageNavigator(true)}
|
{renderPageNavigator(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -1936,12 +1958,10 @@ export default function ReportEditorPage() {
|
|||||||
title="Campi Dati"
|
title="Campi Dati"
|
||||||
icon={<DataIcon />}
|
icon={<DataIcon />}
|
||||||
position="left"
|
position="left"
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={220}
|
|
||||||
maxWidth={400}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
@@ -1951,6 +1971,7 @@ export default function ReportEditorPage() {
|
|||||||
? selectedDatasets.length
|
? selectedDatasets.length
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderDataBindingPanel(true)}
|
{renderDataBindingPanel(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -1964,16 +1985,15 @@ export default function ReportEditorPage() {
|
|||||||
title="Proprietà"
|
title="Proprietà"
|
||||||
icon={<SettingsIcon />}
|
icon={<SettingsIcon />}
|
||||||
position="left"
|
position="left"
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={220}
|
|
||||||
maxWidth={400}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
}
|
}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderPropertiesPanel(true)}
|
{renderPropertiesPanel(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -1981,8 +2001,9 @@ export default function ReportEditorPage() {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Box>
|
</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,9 +2055,19 @@ 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");
|
||||||
|
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") {
|
if (panelState.id === "pages") {
|
||||||
return (
|
return (
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@@ -2044,18 +2075,17 @@ export default function ReportEditorPage() {
|
|||||||
id={panelState.id}
|
id={panelState.id}
|
||||||
title="Pagine"
|
title="Pagine"
|
||||||
icon={<LayersIcon />}
|
icon={<LayersIcon />}
|
||||||
position="right"
|
position={panelState.position}
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={180}
|
|
||||||
maxWidth={350}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
}
|
}
|
||||||
badge={template.pages.length}
|
badge={template.pages.length}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderPageNavigator(true)}
|
{renderPageNavigator(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -2068,13 +2098,11 @@ export default function ReportEditorPage() {
|
|||||||
id={panelState.id}
|
id={panelState.id}
|
||||||
title="Campi Dati"
|
title="Campi Dati"
|
||||||
icon={<DataIcon />}
|
icon={<DataIcon />}
|
||||||
position="right"
|
position={panelState.position}
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={220}
|
|
||||||
maxWidth={400}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
@@ -2084,6 +2112,7 @@ export default function ReportEditorPage() {
|
|||||||
? selectedDatasets.length
|
? selectedDatasets.length
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderDataBindingPanel(true)}
|
{renderDataBindingPanel(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -2096,17 +2125,16 @@ export default function ReportEditorPage() {
|
|||||||
id={panelState.id}
|
id={panelState.id}
|
||||||
title="Proprietà"
|
title="Proprietà"
|
||||||
icon={<SettingsIcon />}
|
icon={<SettingsIcon />}
|
||||||
position="right"
|
position={panelState.position}
|
||||||
width={panelState.width}
|
flex={panelState.flex}
|
||||||
minWidth={220}
|
|
||||||
maxWidth={400}
|
|
||||||
collapsed={panelState.collapsed}
|
collapsed={panelState.collapsed}
|
||||||
onWidthChange={(w) =>
|
onFlexChange={(f) =>
|
||||||
panelLayout.setPanelWidth(panelState.id, w)
|
panelLayout.setPanelFlex(panelState.id, f)
|
||||||
}
|
}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() =>
|
||||||
panelLayout.togglePanelCollapse(panelState.id)
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
}
|
}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{renderPropertiesPanel(true)}
|
{renderPropertiesPanel(true)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -2114,8 +2142,9 @@ export default function ReportEditorPage() {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Box>
|
</SidebarDropZone>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
|
|||||||
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
Binary file not shown.
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user