This commit is contained in:
2025-11-29 02:22:43 +01:00
parent 53c366c20e
commit dc6f223fd9
10 changed files with 967 additions and 115 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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

View 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>
);
}

View 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;

View 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;

View File

@@ -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.