-
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:**
|
**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
|
- **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:
|
- **Soluzione:** Riscritto completamente l'handler delle scorciatoie con:
|
||||||
- Controllo se il focus è su campi input/textarea/contenteditable
|
- Controllo se il focus è su campi input/textarea/contenteditable
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ interface DataBindingPanelProps {
|
|||||||
selectedDatasets: DatasetTypeDto[];
|
selectedDatasets: DatasetTypeDto[];
|
||||||
onInsertBinding: (binding: string) => void;
|
onInsertBinding: (binding: string) => void;
|
||||||
onRemoveDataset: (datasetId: string) => void;
|
onRemoveDataset: (datasetId: string) => void;
|
||||||
|
/** When true, hides borders (used inside CollapsiblePanel) */
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataBindingPanel({
|
export default function DataBindingPanel({
|
||||||
@@ -60,6 +62,7 @@ export default function DataBindingPanel({
|
|||||||
selectedDatasets,
|
selectedDatasets,
|
||||||
onInsertBinding,
|
onInsertBinding,
|
||||||
onRemoveDataset,
|
onRemoveDataset,
|
||||||
|
embedded = false,
|
||||||
}: DataBindingPanelProps) {
|
}: DataBindingPanelProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -205,25 +208,25 @@ export default function DataBindingPanel({
|
|||||||
return count;
|
return count;
|
||||||
}, [search, schemas]);
|
}, [search, schemas]);
|
||||||
|
|
||||||
// Panel width based on context (full width in mobile drawer)
|
// Panel width based on context (full width when embedded or in mobile drawer)
|
||||||
const panelWidth = isMobile ? "100%" : 300;
|
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 300;
|
||||||
|
|
||||||
if (selectedDatasets.length === 0) {
|
if (selectedDatasets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
minWidth: isMobile ? undefined : 300,
|
minWidth: embedded ? undefined : isMobile ? undefined : 300,
|
||||||
borderRight: isMobile ? 0 : 1,
|
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
p: 3,
|
p: 3,
|
||||||
bgcolor: "grey.50",
|
bgcolor: embedded ? "transparent" : "grey.50",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
height: isMobile ? "100%" : undefined,
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
|
<TableIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
|
||||||
@@ -242,18 +245,20 @@ export default function DataBindingPanel({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
minWidth: isMobile ? undefined : 300,
|
minWidth: embedded ? undefined : isMobile ? undefined : 300,
|
||||||
borderRight: isMobile ? 0 : 1,
|
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
height: isMobile ? "100%" : undefined,
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header con ricerca */}
|
{/* Header con ricerca */}
|
||||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
<Box
|
||||||
{!isMobile && (
|
sx={{ p: 2, borderBottom: embedded ? 0 : 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
{!isMobile && !embedded && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -289,7 +294,19 @@ export default function DataBindingPanel({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Lista campi */}
|
{/* 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 */}
|
{/* Dataset Sections */}
|
||||||
{schemas.map((schema) => {
|
{schemas.map((schema) => {
|
||||||
const dataset = selectedDatasets.find(
|
const dataset = selectedDatasets.find(
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface PageNavigatorProps {
|
|||||||
onDeletePage: (pageId: string) => void;
|
onDeletePage: (pageId: string) => void;
|
||||||
onRenamePage: (pageId: string, newName: string) => void;
|
onRenamePage: (pageId: string, newName: string) => void;
|
||||||
onMovePage: (pageId: string, direction: "up" | "down") => void;
|
onMovePage: (pageId: string, direction: "up" | "down") => void;
|
||||||
|
/** When true, hides header and borders (used inside CollapsiblePanel) */
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageNavigator({
|
export default function PageNavigator({
|
||||||
@@ -55,6 +57,7 @@ export default function PageNavigator({
|
|||||||
onDeletePage,
|
onDeletePage,
|
||||||
onRenamePage,
|
onRenamePage,
|
||||||
onMovePage,
|
onMovePage,
|
||||||
|
embedded = false,
|
||||||
}: PageNavigatorProps) {
|
}: PageNavigatorProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -158,23 +161,24 @@ export default function PageNavigator({
|
|||||||
|
|
||||||
const currentMenuIndex = menuAnchor ? getPageIndex(menuAnchor.pageId) : -1;
|
const currentMenuIndex = menuAnchor ? getPageIndex(menuAnchor.pageId) : -1;
|
||||||
|
|
||||||
// Width based on context
|
// Width based on context - full width when embedded in CollapsiblePanel
|
||||||
const panelWidth = isMobile ? "100%" : 220;
|
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 220;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
minWidth: isMobile ? undefined : 180,
|
minWidth: embedded ? undefined : isMobile ? undefined : 180,
|
||||||
borderRight: isMobile ? 0 : 1,
|
borderRight: embedded ? 0 : isMobile ? 0 : 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
bgcolor: "#fafafa",
|
bgcolor: embedded ? "transparent" : "#fafafa",
|
||||||
height: isMobile ? "100%" : undefined,
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header - hide when embedded */}
|
||||||
|
{!embedded && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
@@ -185,7 +189,11 @@ export default function PageNavigator({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" fontWeight={600} color="text.secondary">
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
Pagine ({pages.length})
|
Pagine ({pages.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Aggiungi pagina">
|
<Tooltip title="Aggiungi pagina">
|
||||||
@@ -194,6 +202,7 @@ export default function PageNavigator({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Page List */}
|
{/* Page List */}
|
||||||
<List
|
<List
|
||||||
@@ -202,6 +211,13 @@ export default function PageNavigator({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
py: 0.5,
|
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) => {
|
{pages.map((page, index) => {
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ interface PropertiesPanelProps {
|
|||||||
// Current page for page-specific settings
|
// Current page for page-specific settings
|
||||||
currentPage?: AprtPage;
|
currentPage?: AprtPage;
|
||||||
onUpdateCurrentPage?: (updates: Partial<AprtPage>) => void;
|
onUpdateCurrentPage?: (updates: Partial<AprtPage>) => void;
|
||||||
|
/** When true, hides borders (used inside CollapsiblePanel) */
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PropertiesPanel({
|
export default function PropertiesPanel({
|
||||||
@@ -93,6 +95,7 @@ export default function PropertiesPanel({
|
|||||||
onOpenImageUpload,
|
onOpenImageUpload,
|
||||||
currentPage,
|
currentPage,
|
||||||
onUpdateCurrentPage,
|
onUpdateCurrentPage,
|
||||||
|
embedded = false,
|
||||||
}: PropertiesPanelProps) {
|
}: PropertiesPanelProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -180,8 +183,8 @@ export default function PropertiesPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Panel width based on context
|
// Panel width based on context (full width when embedded)
|
||||||
const panelWidth = isMobile ? "100%" : 280;
|
const panelWidth = embedded ? "100%" : isMobile ? "100%" : 280;
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
// Show page settings when no element selected
|
// Show page settings when no element selected
|
||||||
@@ -189,15 +192,22 @@ export default function PropertiesPanel({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
minWidth: isMobile ? undefined : 280,
|
minWidth: embedded ? undefined : isMobile ? undefined : 280,
|
||||||
borderLeft: isMobile ? 0 : 1,
|
borderLeft: embedded ? 0 : isMobile ? 0 : 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
p: 2,
|
p: 2,
|
||||||
overflow: "auto",
|
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>
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
{currentPage
|
{currentPage
|
||||||
? `Pagina: ${currentPage.name}`
|
? `Pagina: ${currentPage.name}`
|
||||||
@@ -374,13 +384,22 @@ export default function PropertiesPanel({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
minWidth: isMobile ? undefined : 280,
|
minWidth: embedded ? undefined : isMobile ? undefined : 280,
|
||||||
borderLeft: isMobile ? 0 : 1,
|
borderLeft: embedded ? 0 : isMobile ? 0 : 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
overflow: "auto",
|
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
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Element header - show only when not embedded (CollapsiblePanel has its own header) */}
|
||||||
|
{!embedded && (
|
||||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||||
<Typography variant="subtitle2" color="primary">
|
<Typography variant="subtitle2" color="primary">
|
||||||
{element.name || `Elemento ${element.type}`}
|
{element.name || `Elemento ${element.type}`}
|
||||||
@@ -389,6 +408,7 @@ export default function PropertiesPanel({
|
|||||||
{element.type.toUpperCase()} - ID: {element.id.slice(0, 8)}
|
{element.type.toUpperCase()} - ID: {element.id.slice(0, 8)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Position */}
|
{/* Position */}
|
||||||
<Accordion
|
<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";
|
} from "@tanstack/react-query";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useHistory } from "../hooks/useHistory";
|
import { useHistory } from "../hooks/useHistory";
|
||||||
|
import { usePanelLayout } from "../hooks/usePanelLayout";
|
||||||
import { useCollaborationRoom } from "../contexts/CollaborationContext";
|
import { useCollaborationRoom } from "../contexts/CollaborationContext";
|
||||||
import type {
|
import type {
|
||||||
DataChangeMessage,
|
DataChangeMessage,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Description as PageIcon,
|
Description as PageIcon,
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
|
Layers as LayersIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import EditorCanvas, {
|
import EditorCanvas, {
|
||||||
type ContextMenuEvent,
|
type ContextMenuEvent,
|
||||||
@@ -61,6 +63,7 @@ import ImageUploadDialog, {
|
|||||||
type ImageData,
|
type ImageData,
|
||||||
} 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 {
|
import {
|
||||||
reportTemplateService,
|
reportTemplateService,
|
||||||
reportFontService,
|
reportFontService,
|
||||||
@@ -101,7 +104,6 @@ export default function ReportEditorPage() {
|
|||||||
// Responsive breakpoints
|
// Responsive breakpoints
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
|
||||||
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "lg")); // 600-1200px
|
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)
|
// Template state with robust undo/redo (100 states history)
|
||||||
const [templateHistory, historyActions] = useHistory<AprtTemplate>(
|
const [templateHistory, historyActions] = useHistory<AprtTemplate>(
|
||||||
@@ -144,6 +146,9 @@ export default function ReportEditorPage() {
|
|||||||
// Mobile panel state
|
// Mobile panel state
|
||||||
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
|
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
|
||||||
|
|
||||||
|
// Panel layout configuration (persisted to localStorage)
|
||||||
|
const panelLayout = usePanelLayout();
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [saveDialog, setSaveDialog] = useState(false);
|
const [saveDialog, setSaveDialog] = useState(false);
|
||||||
const [previewDialog, setPreviewDialog] = useState(false);
|
const [previewDialog, setPreviewDialog] = useState(false);
|
||||||
@@ -1756,7 +1761,8 @@ export default function ReportEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render panels based on screen size
|
// Render panels based on screen size
|
||||||
const renderPageNavigator = () => (
|
// embedded=true when used inside CollapsiblePanel (removes internal borders/headers)
|
||||||
|
const renderPageNavigator = (embedded = false) => (
|
||||||
<PageNavigator
|
<PageNavigator
|
||||||
pages={template.pages}
|
pages={template.pages}
|
||||||
elements={template.elements}
|
elements={template.elements}
|
||||||
@@ -1767,19 +1773,21 @@ export default function ReportEditorPage() {
|
|||||||
onDeletePage={handleDeletePage}
|
onDeletePage={handleDeletePage}
|
||||||
onRenamePage={handleRenamePage}
|
onRenamePage={handleRenamePage}
|
||||||
onMovePage={handleMovePage}
|
onMovePage={handleMovePage}
|
||||||
|
embedded={embedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDataBindingPanel = () => (
|
const renderDataBindingPanel = (embedded = false) => (
|
||||||
<DataBindingPanel
|
<DataBindingPanel
|
||||||
schemas={schemas}
|
schemas={schemas}
|
||||||
selectedDatasets={selectedDatasets}
|
selectedDatasets={selectedDatasets}
|
||||||
onInsertBinding={handleInsertBinding}
|
onInsertBinding={handleInsertBinding}
|
||||||
onRemoveDataset={handleRemoveDataset}
|
onRemoveDataset={handleRemoveDataset}
|
||||||
|
embedded={embedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPropertiesPanel = () => (
|
const renderPropertiesPanel = (embedded = false) => (
|
||||||
<PropertiesPanel
|
<PropertiesPanel
|
||||||
element={selectedElement || null}
|
element={selectedElement || null}
|
||||||
onUpdateElement={handleUpdateSelectedElement}
|
onUpdateElement={handleUpdateSelectedElement}
|
||||||
@@ -1805,6 +1813,7 @@ export default function ReportEditorPage() {
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
|
embedded={embedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1891,30 +1900,104 @@ 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" }}>
|
||||||
{/* Desktop: Show all panels */}
|
{/* Left Sidebar - Panels on left side */}
|
||||||
{isDesktop && (
|
{!isMobile && (
|
||||||
<>
|
<Box sx={{ display: "flex", height: "100%" }}>
|
||||||
{renderPageNavigator()}
|
{panelLayout.getPanelsForPosition("left").map((panelState) => {
|
||||||
{renderDataBindingPanel()}
|
if (panelState.id === "pages") {
|
||||||
</>
|
return (
|
||||||
)}
|
<ResizablePanel
|
||||||
|
key={panelState.id}
|
||||||
{/* Tablet: Show page navigator and data panel in collapsible sidebars */}
|
id={panelState.id}
|
||||||
{isTablet && (
|
title="Pagine"
|
||||||
<Box
|
icon={<LayersIcon />}
|
||||||
sx={{
|
position="left"
|
||||||
width: 180,
|
width={panelState.width}
|
||||||
borderRight: 1,
|
minWidth={180}
|
||||||
borderColor: "divider",
|
maxWidth={350}
|
||||||
display: "flex",
|
collapsed={panelState.collapsed}
|
||||||
flexDirection: "column",
|
onWidthChange={(w) =>
|
||||||
overflow: "hidden",
|
panelLayout.setPanelWidth(panelState.id, w)
|
||||||
}}
|
}
|
||||||
|
onToggleCollapse={() =>
|
||||||
|
panelLayout.togglePanelCollapse(panelState.id)
|
||||||
|
}
|
||||||
|
badge={template.pages.length}
|
||||||
>
|
>
|
||||||
{renderPageNavigator()}
|
{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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Canvas - show only elements for current page */}
|
{/* Canvas - show only elements for current page */}
|
||||||
<EditorCanvas
|
<EditorCanvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -1948,9 +2031,91 @@ export default function ReportEditorPage() {
|
|||||||
snapOptions={snapOptions}
|
snapOptions={snapOptions}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Desktop/Tablet: Properties Panel */}
|
{/* Right Sidebar - Panels on right side */}
|
||||||
{!isMobile && renderPropertiesPanel()}
|
{!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>
|
</Box>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user