-
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -52,7 +52,34 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
||||
|
||||
**Lavoro completato nell'ultima sessione:**
|
||||
|
||||
- **NUOVA FEATURE: Scorciatoie da Tastiera Complete per Report Designer** - COMPLETATO
|
||||
- **NUOVA FEATURE: Pannelli Ridimensionabili e Collassabili nel Report Designer** - COMPLETATO
|
||||
- **Obiettivo:** I pannelli del report designer devono essere collassabili, ridimensionabili, riposizionabili e la configurazione deve essere persistita
|
||||
- **Nuovi file creati:**
|
||||
- `frontend/src/hooks/useLocalStorage.ts` - Hook generico per persistenza in localStorage
|
||||
- `frontend/src/hooks/usePanelLayout.ts` - Hook per gestire configurazione pannelli (larghezza, collapsed, posizione)
|
||||
- `frontend/src/components/reportEditor/ResizablePanel.tsx` - Componente pannello ridimensionabile con:
|
||||
- Handle di resize trascinabile sul bordo
|
||||
- Stato collassato con icona, badge e titolo verticale
|
||||
- Header compatto con titolo, icona e pulsante collasso
|
||||
- Nessuna scrollbar visibile (hidden ma funzionale)
|
||||
- **Configurazione pannelli:**
|
||||
- Salvata in localStorage con chiave `apollinare-report-editor-panels`
|
||||
- Default: Pages (sinistra, 220px), Data (destra, 280px), Properties (destra, 280px)
|
||||
- Ogni pannello ha: id, width, collapsed, position (left/right), order
|
||||
- **Scrollbar nascoste:** Usato CSS per nascondere scrollbar mantenendo funzionalità scroll
|
||||
- `&::-webkit-scrollbar: { width: 0 }` per Chrome/Safari
|
||||
- `scrollbarWidth: "none"` per Firefox
|
||||
- `msOverflowStyle: "none"` per IE/Edge
|
||||
- **Canvas centrato:** La viewport del report rimane sempre al centro dell'area disponibile
|
||||
- **File modificati:**
|
||||
- `ReportEditorPage.tsx` - Integrato `usePanelLayout`, usato `ResizablePanel` per tutti i pannelli
|
||||
- `PageNavigator.tsx` - Aggiunto CSS per nascondere scrollbar
|
||||
- `DataBindingPanel.tsx` - Aggiunto CSS per nascondere scrollbar
|
||||
- `PropertiesPanel.tsx` - Aggiunto CSS per nascondere scrollbar
|
||||
- **File rimossi:**
|
||||
- `CollapsiblePanel.tsx` - Sostituito da `ResizablePanel`
|
||||
|
||||
- **NUOVA FEATURE: Panning e Zoom stile Draw.io nel Report Designer** - COMPLETATO (sessione precedente)
|
||||
- **Problema:** Le scorciatoie da tastiera (Ctrl+X, Ctrl+C, etc.) venivano intercettate dal browser invece che dalla pagina
|
||||
- **Soluzione:** Riscritto completamente l'handler delle scorciatoie con:
|
||||
- Controllo se il focus è su campi input/textarea/contenteditable
|
||||
|
||||
@@ -53,6 +53,8 @@ interface DataBindingPanelProps {
|
||||
selectedDatasets: DatasetTypeDto[];
|
||||
onInsertBinding: (binding: string) => void;
|
||||
onRemoveDataset: (datasetId: string) => void;
|
||||
/** When true, hides borders (used inside CollapsiblePanel) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function DataBindingPanel({
|
||||
@@ -60,6 +62,7 @@ export default function DataBindingPanel({
|
||||
selectedDatasets,
|
||||
onInsertBinding,
|
||||
onRemoveDataset,
|
||||
embedded = false,
|
||||
}: DataBindingPanelProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
@@ -205,25 +208,25 @@ export default function DataBindingPanel({
|
||||
return count;
|
||||
}, [search, schemas]);
|
||||
|
||||
// Panel width based on context (full width in mobile drawer)
|
||||
const panelWidth = isMobile ? "100%" : 300;
|
||||
// Panel width based on context (full width when embedded or in mobile drawer)
|
||||
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 300;
|
||||
|
||||
if (selectedDatasets.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: panelWidth,
|
||||
minWidth: isMobile ? undefined : 300,
|
||||
borderRight: isMobile ? 0 : 1,
|
||||
minWidth: embedded ? undefined : isMobile ? undefined : 300,
|
||||
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
p: 3,
|
||||
bgcolor: "grey.50",
|
||||
bgcolor: embedded ? "transparent" : "grey.50",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<TableIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
|
||||
@@ -242,18 +245,20 @@ export default function DataBindingPanel({
|
||||
<Box
|
||||
sx={{
|
||||
width: panelWidth,
|
||||
minWidth: isMobile ? undefined : 300,
|
||||
borderRight: isMobile ? 0 : 1,
|
||||
minWidth: embedded ? undefined : isMobile ? undefined : 300,
|
||||
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
bgcolor: "background.paper",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header con ricerca */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
{!isMobile && (
|
||||
<Box
|
||||
sx={{ p: 2, borderBottom: embedded ? 0 : 1, borderColor: "divider" }}
|
||||
>
|
||||
{!isMobile && !embedded && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="primary"
|
||||
@@ -289,7 +294,19 @@ export default function DataBindingPanel({
|
||||
</Box>
|
||||
|
||||
{/* Lista campi */}
|
||||
<Box sx={{ overflow: "auto", flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "auto",
|
||||
flex: 1,
|
||||
// Hide scrollbar but keep functionality
|
||||
"&::-webkit-scrollbar": {
|
||||
width: 0,
|
||||
background: "transparent",
|
||||
},
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE/Edge
|
||||
}}
|
||||
>
|
||||
{/* Dataset Sections */}
|
||||
{schemas.map((schema) => {
|
||||
const dataset = selectedDatasets.find(
|
||||
|
||||
@@ -43,6 +43,8 @@ interface PageNavigatorProps {
|
||||
onDeletePage: (pageId: string) => void;
|
||||
onRenamePage: (pageId: string, newName: string) => void;
|
||||
onMovePage: (pageId: string, direction: "up" | "down") => void;
|
||||
/** When true, hides header and borders (used inside CollapsiblePanel) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function PageNavigator({
|
||||
@@ -55,6 +57,7 @@ export default function PageNavigator({
|
||||
onDeletePage,
|
||||
onRenamePage,
|
||||
onMovePage,
|
||||
embedded = false,
|
||||
}: PageNavigatorProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
@@ -158,42 +161,48 @@ export default function PageNavigator({
|
||||
|
||||
const currentMenuIndex = menuAnchor ? getPageIndex(menuAnchor.pageId) : -1;
|
||||
|
||||
// Width based on context
|
||||
const panelWidth = isMobile ? "100%" : 220;
|
||||
// Width based on context - full width when embedded in CollapsiblePanel
|
||||
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 220;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: panelWidth,
|
||||
minWidth: isMobile ? undefined : 180,
|
||||
borderRight: isMobile ? 0 : 1,
|
||||
minWidth: embedded ? undefined : isMobile ? undefined : 180,
|
||||
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
bgcolor: "#fafafa",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
bgcolor: embedded ? "transparent" : "#fafafa",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600} color="text.secondary">
|
||||
Pagine ({pages.length})
|
||||
</Typography>
|
||||
<Tooltip title="Aggiungi pagina">
|
||||
<IconButton size="small" onClick={onAddPage} color="primary">
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{/* Header - hide when embedded */}
|
||||
{!embedded && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
>
|
||||
Pagine ({pages.length})
|
||||
</Typography>
|
||||
<Tooltip title="Aggiungi pagina">
|
||||
<IconButton size="small" onClick={onAddPage} color="primary">
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Page List */}
|
||||
<List
|
||||
@@ -202,6 +211,13 @@ export default function PageNavigator({
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
py: 0.5,
|
||||
// Hide scrollbar but keep functionality
|
||||
"&::-webkit-scrollbar": {
|
||||
width: 0,
|
||||
background: "transparent",
|
||||
},
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE/Edge
|
||||
}}
|
||||
>
|
||||
{pages.map((page, index) => {
|
||||
|
||||
@@ -78,6 +78,8 @@ interface PropertiesPanelProps {
|
||||
// Current page for page-specific settings
|
||||
currentPage?: AprtPage;
|
||||
onUpdateCurrentPage?: (updates: Partial<AprtPage>) => void;
|
||||
/** When true, hides borders (used inside CollapsiblePanel) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function PropertiesPanel({
|
||||
@@ -93,6 +95,7 @@ export default function PropertiesPanel({
|
||||
onOpenImageUpload,
|
||||
currentPage,
|
||||
onUpdateCurrentPage,
|
||||
embedded = false,
|
||||
}: PropertiesPanelProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
@@ -180,8 +183,8 @@ export default function PropertiesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Panel width based on context
|
||||
const panelWidth = isMobile ? "100%" : 280;
|
||||
// Panel width based on context (full width when embedded)
|
||||
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 280;
|
||||
|
||||
if (!element) {
|
||||
// Show page settings when no element selected
|
||||
@@ -189,15 +192,22 @@ export default function PropertiesPanel({
|
||||
<Box
|
||||
sx={{
|
||||
width: panelWidth,
|
||||
minWidth: isMobile ? undefined : 280,
|
||||
borderLeft: isMobile ? 0 : 1,
|
||||
minWidth: embedded ? undefined : isMobile ? undefined : 280,
|
||||
borderLeft: embedded ? 0 : isMobile ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
p: 2,
|
||||
overflow: "auto",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
height: "100%",
|
||||
// Hide scrollbar but keep functionality
|
||||
"&::-webkit-scrollbar": {
|
||||
width: 0,
|
||||
background: "transparent",
|
||||
},
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE/Edge
|
||||
}}
|
||||
>
|
||||
{!isMobile && (
|
||||
{!isMobile && !embedded && (
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{currentPage
|
||||
? `Pagina: ${currentPage.name}`
|
||||
@@ -374,21 +384,31 @@ export default function PropertiesPanel({
|
||||
<Box
|
||||
sx={{
|
||||
width: panelWidth,
|
||||
minWidth: isMobile ? undefined : 280,
|
||||
borderLeft: isMobile ? 0 : 1,
|
||||
minWidth: embedded ? undefined : isMobile ? undefined : 280,
|
||||
borderLeft: embedded ? 0 : isMobile ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
overflow: "auto",
|
||||
height: isMobile ? "100%" : undefined,
|
||||
height: "100%",
|
||||
// Hide scrollbar but keep functionality
|
||||
"&::-webkit-scrollbar": {
|
||||
width: 0,
|
||||
background: "transparent",
|
||||
},
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE/Edge
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{element.name || `Elemento ${element.type}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{element.type.toUpperCase()} - ID: {element.id.slice(0, 8)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Element header - show only when not embedded (CollapsiblePanel has its own header) */}
|
||||
{!embedded && (
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{element.name || `Elemento ${element.type}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{element.type.toUpperCase()} - ID: {element.id.slice(0, 8)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Position */}
|
||||
<Accordion
|
||||
|
||||
370
frontend/src/components/reportEditor/ResizablePanel.tsx
Normal file
370
frontend/src/components/reportEditor/ResizablePanel.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { Box, IconButton, Tooltip, Typography, alpha } from "@mui/material";
|
||||
import {
|
||||
ChevronLeft as CollapseLeftIcon,
|
||||
ChevronRight as CollapseRightIcon,
|
||||
DragIndicator as DragIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
export interface PanelConfig {
|
||||
id: string;
|
||||
width: number;
|
||||
collapsed: boolean;
|
||||
position: "left" | "right";
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface ResizablePanelProps {
|
||||
/** Unique panel identifier */
|
||||
id: string;
|
||||
/** Panel title shown in header and tooltip when collapsed */
|
||||
title: string;
|
||||
/** Icon shown when panel is collapsed */
|
||||
icon: ReactNode;
|
||||
/** Panel position - determines collapse button direction and resize handle position */
|
||||
position: "left" | "right";
|
||||
/** Current width in pixels */
|
||||
width: number;
|
||||
/** Minimum width when expanded */
|
||||
minWidth?: number;
|
||||
/** Maximum width when expanded */
|
||||
maxWidth?: 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 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;
|
||||
}
|
||||
|
||||
export default function ResizablePanel({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
position,
|
||||
width,
|
||||
minWidth = 200,
|
||||
maxWidth = 500,
|
||||
collapsed,
|
||||
collapsedWidth = 44,
|
||||
onWidthChange,
|
||||
onToggleCollapse,
|
||||
children,
|
||||
badge,
|
||||
draggable = false,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: ResizablePanelProps) {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(width);
|
||||
|
||||
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;
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
// Handle mouse move during resize
|
||||
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]);
|
||||
|
||||
// Collapsed state - show icon strip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Box
|
||||
ref={panelRef}
|
||||
data-panel-id={id}
|
||||
sx={{
|
||||
width: collapsedWidth,
|
||||
minWidth: collapsedWidth,
|
||||
height: "100%",
|
||||
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,
|
||||
borderColor: "divider",
|
||||
py: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Expand button */}
|
||||
<Tooltip title={`Espandi: ${title}`} placement={position === "left" ? "right" : "left"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onToggleCollapse}
|
||||
sx={{
|
||||
mb: 1,
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08),
|
||||
"&:hover": {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExpandIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Panel icon with tooltip */}
|
||||
<Tooltip title={title} 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={{
|
||||
color: "primary.main",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
{badge && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
borderRadius: "10px",
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* Vertical title */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "mixed",
|
||||
transform: "rotate(180deg)",
|
||||
color: "text.secondary",
|
||||
mt: 2,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: 500,
|
||||
fontSize: "0.7rem",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
color: "primary.main",
|
||||
},
|
||||
}}
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<Box
|
||||
ref={panelRef}
|
||||
data-panel-id={id}
|
||||
sx={{
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: position === "right" ? 1 : 0,
|
||||
borderRight: position === "left" ? 1 : 0,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* 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 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.03),
|
||||
minHeight: 40,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75, minWidth: 0, flex: 1 }}>
|
||||
{/* Drag handle */}
|
||||
{draggable && (
|
||||
<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>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{badge && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
borderRadius: "10px",
|
||||
px: 0.6,
|
||||
py: 0.15,
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
textAlign: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Comprimi">
|
||||
<IconButton size="small" onClick={onToggleCollapse} sx={{ flexShrink: 0 }}>
|
||||
<CollapseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Content - no internal scrollbar, content must handle its own overflow */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
79
frontend/src/hooks/useLocalStorage.ts
Normal file
79
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Hook to persist state in localStorage with automatic serialization/deserialization
|
||||
* @param key - localStorage key
|
||||
* @param initialValue - default value if nothing in storage
|
||||
* @returns [value, setValue, removeValue]
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
||||
// Get stored value or use initial
|
||||
const readValue = useCallback((): T => {
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [initialValue, key]);
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(readValue);
|
||||
|
||||
// Return a wrapped version of useState's setter function that persists to localStorage
|
||||
const setValue = useCallback(
|
||||
(value: T | ((prev: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
// Remove from localStorage
|
||||
const removeValue = useCallback(() => {
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
setStoredValue(initialValue);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [initialValue, key]);
|
||||
|
||||
// Listen to storage changes from other tabs
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === key && e.newValue !== null) {
|
||||
try {
|
||||
setStoredValue(JSON.parse(e.newValue) as T);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, [key]);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
158
frontend/src/hooks/usePanelLayout.ts
Normal file
158
frontend/src/hooks/usePanelLayout.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useLocalStorage } from "./useLocalStorage";
|
||||
|
||||
export interface PanelState {
|
||||
id: string;
|
||||
width: number;
|
||||
collapsed: boolean;
|
||||
position: "left" | "right";
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface PanelLayoutConfig {
|
||||
panels: PanelState[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "apollinare-report-editor-panels";
|
||||
const CONFIG_VERSION = 1;
|
||||
|
||||
// Default panel configuration
|
||||
const defaultConfig: PanelLayoutConfig = {
|
||||
version: CONFIG_VERSION,
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
export function usePanelLayout() {
|
||||
const [config, setConfig] = useLocalStorage<PanelLayoutConfig>(
|
||||
STORAGE_KEY,
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
// Ensure config is up to date (handle version migrations)
|
||||
const normalizedConfig = useMemo(() => {
|
||||
if (!config || config.version !== CONFIG_VERSION) {
|
||||
return defaultConfig;
|
||||
}
|
||||
return config;
|
||||
}, [config]);
|
||||
|
||||
// Get panel state by id
|
||||
const getPanelState = useCallback(
|
||||
(panelId: string): PanelState | undefined => {
|
||||
return normalizedConfig.panels.find((p) => p.id === panelId);
|
||||
},
|
||||
[normalizedConfig.panels]
|
||||
);
|
||||
|
||||
// Update a single panel's state
|
||||
const updatePanel = useCallback(
|
||||
(panelId: string, updates: Partial<Omit<PanelState, "id">>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
panels: prev.panels.map((p) =>
|
||||
p.id === panelId ? { ...p, ...updates } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setConfig]
|
||||
);
|
||||
|
||||
// Toggle panel collapse state
|
||||
const togglePanelCollapse = useCallback(
|
||||
(panelId: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
panels: prev.panels.map((p) =>
|
||||
p.id === panelId ? { ...p, collapsed: !p.collapsed } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setConfig]
|
||||
);
|
||||
|
||||
// Update panel width
|
||||
const setPanelWidth = useCallback(
|
||||
(panelId: string, width: number) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
panels: prev.panels.map((p) =>
|
||||
p.id === panelId ? { ...p, width } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setConfig]
|
||||
);
|
||||
|
||||
// Move panel to a different position (left/right sidebar)
|
||||
const movePanelToPosition = useCallback(
|
||||
(panelId: string, newPosition: "left" | "right", newOrder?: number) => {
|
||||
setConfig((prev) => {
|
||||
const panelsInNewPosition = prev.panels.filter(
|
||||
(p) => p.position === newPosition && 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
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[setConfig]
|
||||
);
|
||||
|
||||
// Reorder panels within a sidebar
|
||||
const reorderPanels = useCallback(
|
||||
(position: "left" | "right", orderedPanelIds: string[]) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
panels: prev.panels.map((p) => {
|
||||
if (p.position !== position) return p;
|
||||
const newOrder = orderedPanelIds.indexOf(p.id);
|
||||
return newOrder >= 0 ? { ...p, order: newOrder } : p;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[setConfig]
|
||||
);
|
||||
|
||||
// Get panels for a specific position, sorted by order
|
||||
const getPanelsForPosition = useCallback(
|
||||
(position: "left" | "right"): PanelState[] => {
|
||||
return normalizedConfig.panels
|
||||
.filter((p) => p.position === position)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
},
|
||||
[normalizedConfig.panels]
|
||||
);
|
||||
|
||||
// Reset to default configuration
|
||||
const resetToDefault = useCallback(() => {
|
||||
setConfig(defaultConfig);
|
||||
}, [setConfig]);
|
||||
|
||||
return {
|
||||
config: normalizedConfig,
|
||||
getPanelState,
|
||||
updatePanel,
|
||||
togglePanelCollapse,
|
||||
setPanelWidth,
|
||||
movePanelToPosition,
|
||||
reorderPanels,
|
||||
getPanelsForPosition,
|
||||
resetToDefault,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePanelLayout;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useHistory } from "../hooks/useHistory";
|
||||
import { usePanelLayout } from "../hooks/usePanelLayout";
|
||||
import { useCollaborationRoom } from "../contexts/CollaborationContext";
|
||||
import type {
|
||||
DataChangeMessage,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
Settings as SettingsIcon,
|
||||
Description as PageIcon,
|
||||
Close as CloseIcon,
|
||||
Layers as LayersIcon,
|
||||
} from "@mui/icons-material";
|
||||
import EditorCanvas, {
|
||||
type ContextMenuEvent,
|
||||
@@ -61,6 +63,7 @@ import ImageUploadDialog, {
|
||||
type ImageData,
|
||||
} from "../components/reportEditor/ImageUploadDialog";
|
||||
import PageNavigator from "../components/reportEditor/PageNavigator";
|
||||
import ResizablePanel from "../components/reportEditor/ResizablePanel";
|
||||
import {
|
||||
reportTemplateService,
|
||||
reportFontService,
|
||||
@@ -101,7 +104,6 @@ export default function ReportEditorPage() {
|
||||
// Responsive breakpoints
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
|
||||
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "lg")); // 600-1200px
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up("lg")); // > 1200px
|
||||
|
||||
// Template state with robust undo/redo (100 states history)
|
||||
const [templateHistory, historyActions] = useHistory<AprtTemplate>(
|
||||
@@ -144,6 +146,9 @@ export default function ReportEditorPage() {
|
||||
// Mobile panel state
|
||||
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
|
||||
|
||||
// Panel layout configuration (persisted to localStorage)
|
||||
const panelLayout = usePanelLayout();
|
||||
|
||||
// UI state
|
||||
const [saveDialog, setSaveDialog] = useState(false);
|
||||
const [previewDialog, setPreviewDialog] = useState(false);
|
||||
@@ -1756,7 +1761,8 @@ export default function ReportEditorPage() {
|
||||
}
|
||||
|
||||
// Render panels based on screen size
|
||||
const renderPageNavigator = () => (
|
||||
// embedded=true when used inside CollapsiblePanel (removes internal borders/headers)
|
||||
const renderPageNavigator = (embedded = false) => (
|
||||
<PageNavigator
|
||||
pages={template.pages}
|
||||
elements={template.elements}
|
||||
@@ -1767,19 +1773,21 @@ export default function ReportEditorPage() {
|
||||
onDeletePage={handleDeletePage}
|
||||
onRenamePage={handleRenamePage}
|
||||
onMovePage={handleMovePage}
|
||||
embedded={embedded}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDataBindingPanel = () => (
|
||||
const renderDataBindingPanel = (embedded = false) => (
|
||||
<DataBindingPanel
|
||||
schemas={schemas}
|
||||
selectedDatasets={selectedDatasets}
|
||||
onInsertBinding={handleInsertBinding}
|
||||
onRemoveDataset={handleRemoveDataset}
|
||||
embedded={embedded}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderPropertiesPanel = () => (
|
||||
const renderPropertiesPanel = (embedded = false) => (
|
||||
<PropertiesPanel
|
||||
element={selectedElement || null}
|
||||
onUpdateElement={handleUpdateSelectedElement}
|
||||
@@ -1805,6 +1813,7 @@ export default function ReportEditorPage() {
|
||||
),
|
||||
}));
|
||||
}}
|
||||
embedded={embedded}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1891,66 +1900,222 @@ export default function ReportEditorPage() {
|
||||
|
||||
{/* Main Editor Area */}
|
||||
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{/* Desktop: Show all panels */}
|
||||
{isDesktop && (
|
||||
<>
|
||||
{renderPageNavigator()}
|
||||
{renderDataBindingPanel()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tablet: Show page navigator and data panel in collapsible sidebars */}
|
||||
{isTablet && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 180,
|
||||
borderRight: 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{renderPageNavigator()}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Canvas - show only elements for current page */}
|
||||
<EditorCanvas
|
||||
ref={canvasRef}
|
||||
template={{
|
||||
...template,
|
||||
elements: currentPageElements,
|
||||
// Use current page settings if available, otherwise template defaults
|
||||
meta: {
|
||||
...template.meta,
|
||||
pageSize:
|
||||
(currentPage?.pageSize as PageSize) || template.meta.pageSize,
|
||||
orientation:
|
||||
(currentPage?.orientation as PageOrientation) ||
|
||||
template.meta.orientation,
|
||||
margins: currentPage?.margins || template.meta.margins,
|
||||
},
|
||||
{/* Center Area - Canvas Container (flex: 1 to take remaining space, centers the canvas) */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
overflow: "auto",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "#1a1a1a" : "#e0e0e0",
|
||||
position: "relative",
|
||||
}}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElement={(ids) => {
|
||||
setSelectedElementIds(ids);
|
||||
// On mobile, auto-open properties when selecting element
|
||||
if (isMobile && ids.length > 0) {
|
||||
setMobilePanel("properties");
|
||||
}
|
||||
}}
|
||||
onUpdateElement={handleUpdateElementWithoutHistory}
|
||||
onUpdateElementComplete={historyActions.commit}
|
||||
zoom={zoom}
|
||||
showGrid={showGrid}
|
||||
gridSize={gridSize}
|
||||
snapOptions={snapOptions}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
>
|
||||
{/* Canvas - show only elements for current page */}
|
||||
<EditorCanvas
|
||||
ref={canvasRef}
|
||||
template={{
|
||||
...template,
|
||||
elements: currentPageElements,
|
||||
// Use current page settings if available, otherwise template defaults
|
||||
meta: {
|
||||
...template.meta,
|
||||
pageSize:
|
||||
(currentPage?.pageSize as PageSize) || template.meta.pageSize,
|
||||
orientation:
|
||||
(currentPage?.orientation as PageOrientation) ||
|
||||
template.meta.orientation,
|
||||
margins: currentPage?.margins || template.meta.margins,
|
||||
},
|
||||
}}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElement={(ids) => {
|
||||
setSelectedElementIds(ids);
|
||||
// On mobile, auto-open properties when selecting element
|
||||
if (isMobile && ids.length > 0) {
|
||||
setMobilePanel("properties");
|
||||
}
|
||||
}}
|
||||
onUpdateElement={handleUpdateElementWithoutHistory}
|
||||
onUpdateElementComplete={historyActions.commit}
|
||||
zoom={zoom}
|
||||
showGrid={showGrid}
|
||||
gridSize={gridSize}
|
||||
snapOptions={snapOptions}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Desktop/Tablet: Properties Panel */}
|
||||
{!isMobile && renderPropertiesPanel()}
|
||||
{/* 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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user