Files
zentral/frontend/src/pages/ReportEditorPage.tsx
2025-11-29 02:22:43 +01:00

2375 lines
72 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
useQuery,
useQueries,
useMutation,
useQueryClient,
} 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,
ItemCreatedMessage,
ItemDeletedMessage,
} from "../services/collaboration";
import {
Box,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Snackbar,
useMediaQuery,
useTheme,
IconButton,
BottomNavigation,
BottomNavigationAction,
Paper,
SwipeableDrawer,
Typography,
} from "@mui/material";
import {
Storage as DataIcon,
Settings as SettingsIcon,
Description as PageIcon,
Close as CloseIcon,
Layers as LayersIcon,
} from "@mui/icons-material";
import EditorCanvas, {
type ContextMenuEvent,
type EditorCanvasRef,
} from "../components/reportEditor/EditorCanvas";
import EditorToolbar, {
type SnapOptions,
} from "../components/reportEditor/EditorToolbar";
import ContextMenu from "../components/reportEditor/ContextMenu";
import PropertiesPanel from "../components/reportEditor/PropertiesPanel";
import DataBindingPanel from "../components/reportEditor/DataBindingPanel";
import DatasetSelector from "../components/reportEditor/DatasetSelector";
import PreviewDialog from "../components/reportEditor/PreviewDialog";
import DatasetManagerDialog from "../components/reportEditor/DatasetManagerDialog";
import ImageUploadDialog, {
type ImageData,
} from "../components/reportEditor/ImageUploadDialog";
import PageNavigator from "../components/reportEditor/PageNavigator";
import ResizablePanel from "../components/reportEditor/ResizablePanel";
import {
reportTemplateService,
reportFontService,
reportGeneratorService,
virtualDatasetService,
openBlobInNewTab,
} from "../services/reportService";
import type {
AprtTemplate,
AprtElement,
AprtPage,
ElementType,
PageSize,
PageOrientation,
AprtMargins,
DataSchemaDto,
DatasetTypeDto,
DataSourceSelection,
ReportTemplateDto,
} from "../types/report";
import {
defaultTemplate,
defaultStyle,
defaultImageSettings,
defaultPage,
} from "../types/report";
// Panel types for mobile navigation
type MobilePanel = "pages" | "data" | "properties" | null;
export default function ReportEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isNew = !id;
const theme = useTheme();
// Responsive breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "lg")); // 600-1200px
// Template state with robust undo/redo (100 states history)
const [templateHistory, historyActions] = useHistory<AprtTemplate>(
defaultTemplate,
{ maxHistoryLength: 100 },
);
const template = templateHistory.current;
const [templateInfo, setTemplateInfo] = useState<{
nome: string;
descrizione: string;
categoria: string;
}>({
nome: "Nuovo Template",
descrizione: "",
categoria: "Generale",
});
// Dataset state
const [selectedDatasets, setSelectedDatasets] = useState<DatasetTypeDto[]>(
[],
);
// Page state - track which page is currently being edited
const [currentPageId, setCurrentPageId] = useState<string>(defaultPage.id);
// Editor state - support multiple selection
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [zoom, setZoom] = useState(isMobile ? 0.5 : 1);
const [showGrid, setShowGrid] = useState(true);
const [snapOptions, setSnapOptions] = useState<SnapOptions>({
grid: false,
objects: true,
borders: true,
center: true,
tangent: true,
});
const [gridSize] = useState(5); // 5mm grid
// 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);
const [datasetManagerDialog, setDatasetManagerDialog] = useState(false);
const [imageUploadDialog, setImageUploadDialog] = useState(false);
const [isGeneratingPreview, setIsGeneratingPreview] = useState(false);
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: "success" | "error";
}>({
open: false,
message: "",
severity: "success",
});
// Context menu state
const [contextMenu, setContextMenu] = useState<{
open: boolean;
position: { x: number; y: number } | null;
}>({
open: false,
position: null,
});
const [clipboard, setClipboard] = useState<AprtElement | null>(null);
// Track unsaved changes - reset when saved, set when modified
const [lastSavedUndoCount, setLastSavedUndoCount] = useState(0);
const hasUnsavedChanges = templateHistory.undoCount !== lastSavedUndoCount;
// Auto-save feature - enabled by default
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
// Template version for collaboration sync (increments on each save)
const templateVersionRef = useRef(0);
// Ref to EditorCanvas for checking text editing state
const canvasRef = useRef<EditorCanvasRef>(null);
// ============ COLLABORATION (using global context) ============
// Room key format: "report-template:{id}"
const roomKey = id ? `report-template:${id}` : null;
const collaboration = useCollaborationRoom(roomKey, {
enabled: !isNew && !!id,
});
// Flag to prevent re-broadcasting received changes
const isApplyingRemoteChange = useRef(false);
// Update zoom on screen size change
useEffect(() => {
if (isMobile) {
setZoom(0.5);
} else if (isTablet) {
setZoom(0.75);
} else {
setZoom(1);
}
}, [isMobile, isTablet]);
// ============ COLLABORATION EFFECTS ============
// The collaboration context handles connection, room joining, and presence automatically.
// We only need to subscribe to data change events and send our changes.
// Subscribe to remote data changes
useEffect(() => {
if (!collaboration.isConnected || !collaboration.currentRoom) return;
const unsubscribers: (() => void)[] = [];
// Element/data changed by remote user
unsubscribers.push(
collaboration.onDataChanged((message: DataChangeMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === message.itemId
? { ...el, ...(message.newValue as Partial<AprtElement>) }
: el,
),
}));
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Element added by remote user
unsubscribers.push(
collaboration.onItemCreated((message: ItemCreatedMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: [...prev.elements, message.item as AprtElement],
}));
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Element deleted by remote user
unsubscribers.push(
collaboration.onItemDeleted((message: ItemDeletedMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: prev.elements.filter((el) => el.id !== message.itemId),
}));
// Clear selection if deleted element was selected
if (selectedElementIds.includes(message.itemId)) {
setSelectedElementIds((prev) =>
prev.filter((id) => id !== message.itemId),
);
}
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Page changes by remote user
unsubscribers.push(
collaboration.onDataChanged((message: DataChangeMessage) => {
if (message.itemType !== "page") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => {
switch (message.changeType) {
case "added":
return {
...prev,
pages: [...prev.pages, message.newValue as AprtPage],
};
case "deleted":
return {
...prev,
pages: prev.pages.filter((p) => p.id !== message.itemId),
elements: prev.elements.filter(
(e) => e.pageId !== message.itemId,
),
};
case "renamed":
return {
...prev,
pages: prev.pages.map((p) =>
p.id === message.itemId
? { ...p, name: message.newValue as string }
: p,
),
};
case "reordered":
return {
...prev,
pages: message.newValue as AprtPage[],
};
case "settings":
return {
...prev,
pages: prev.pages.map((p) =>
p.id === message.itemId
? { ...p, ...(message.newValue as Partial<AprtPage>) }
: p,
),
};
default:
return prev;
}
});
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Template sync received - apply template directly without server reload
unsubscribers.push(
collaboration.onTemplateSync((message) => {
console.log(
`[Collaboration] Received TemplateSync, version ${message.version}, size: ${message.templateJson.length} bytes`,
);
// Only apply if the received version is newer
if (message.version <= templateVersionRef.current) {
console.log(
`[Collaboration] Ignoring older template version ${message.version} (current: ${templateVersionRef.current})`,
);
return;
}
try {
isApplyingRemoteChange.current = true;
const receivedTemplate = JSON.parse(
message.templateJson,
) as AprtTemplate;
// Update version to received version
templateVersionRef.current = message.version;
// Apply template without adding to history (it's a sync, not a user action)
historyActions.setWithoutHistory(() => receivedTemplate);
// Show notification
setSnackbar({
open: true,
message: "Template aggiornato da un altro utente",
severity: "success",
});
// Also update the query cache so it stays in sync
queryClient.setQueryData(["report-template", id], (old: unknown) => {
if (!old) return old;
return {
...(old as Record<string, unknown>),
templateJson: message.templateJson,
};
});
} catch (e) {
console.error("[Collaboration] Error parsing received template:", e);
} finally {
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}
}),
);
// Legacy DataSaved handler (for backwards compatibility - will be removed)
unsubscribers.push(
collaboration.onDataSaved((message) => {
console.log(
"[Collaboration] Received DataSaved from:",
message.savedBy,
"(legacy - should use TemplateSync instead)",
);
// Only reload from server if we haven't received a TemplateSync
// This is a fallback for older clients
}),
);
// Sync requested - send current template to requester
unsubscribers.push(
collaboration.onSyncRequested((request) => {
collaboration.sendSync(request.requesterId, JSON.stringify(template));
}),
);
return () => {
unsubscribers.forEach((unsub) => unsub());
};
}, [
collaboration,
historyActions,
selectedElementIds,
template,
queryClient,
id,
]);
// Send selection changes to collaborators (send first selected element for compatibility)
useEffect(() => {
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendSelectionChanged(selectedElementIds[0] || null);
}
}, [collaboration, selectedElementIds]);
// Send view/page navigation to collaborators
useEffect(() => {
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendViewChanged(currentPageId);
}
}, [collaboration, currentPageId]);
// Load existing template
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
queryKey: ["report-template", id],
queryFn: () => reportTemplateService.getById(Number(id)),
enabled: !!id,
});
// Load available datasets
const { data: availableDatasets = [] } = useQuery({
queryKey: ["available-datasets"],
queryFn: () => reportGeneratorService.getAvailableDatasets(),
});
// Virtual datasets are now loaded via getAvailableDatasets API
// This query is only for DatasetManagerDialog internal use
useQuery({
queryKey: ["virtual-datasets"],
queryFn: () => virtualDatasetService.getAll(),
enabled: datasetManagerDialog, // Only fetch when dialog is open
});
// Load schemas for selected datasets
const schemaQueries = useQueries({
queries: selectedDatasets.map((dataset) => ({
queryKey: ["report-schema", dataset.id],
queryFn: () => reportGeneratorService.getSchema(dataset.id),
enabled: selectedDatasets.length > 0,
staleTime: 60000,
})),
});
const schemas = schemaQueries
.filter((q) => q.isSuccess && q.data)
.map((q) => q.data as DataSchemaDto);
// Create a map of datasetId -> schema for PropertiesPanel
const dataSchemaMap = schemas.reduce(
(acc, schema) => {
acc[schema.datasetId] = schema;
return acc;
},
{} as Record<string, DataSchemaDto>,
);
// Load font families
const {
data: fontFamilies = ["Helvetica", "Times New Roman", "Courier", "Arial"],
} = useQuery({
queryKey: ["font-families"],
queryFn: () => reportFontService.getFamilies(),
});
// Initialize template from loaded data
useEffect(() => {
if (existingTemplate) {
try {
const parsed = JSON.parse(
existingTemplate.templateJson || "{}",
) as Partial<AprtTemplate>;
// Merge with defaultTemplate to ensure all required fields exist
const mergedTemplate: AprtTemplate = {
...defaultTemplate,
...parsed,
meta: {
...defaultTemplate.meta,
...(parsed.meta || {}),
margins: {
...defaultTemplate.meta.margins,
...(parsed.meta?.margins || {}),
},
},
resources: {
fonts: parsed.resources?.fonts || [],
images: parsed.resources?.images || [],
},
dataSources: parsed.dataSources || {},
sections: parsed.sections || [],
pages:
parsed.pages && parsed.pages.length > 0
? parsed.pages
: [{ ...defaultPage }],
elements: parsed.elements || [],
};
// Migrate legacy elements without pageId to first page
const firstPageId = mergedTemplate.pages[0].id;
mergedTemplate.elements.forEach((el) => {
if (!el.pageId) {
el.pageId = firstPageId;
}
});
// Set current page to first page
setCurrentPageId(firstPageId);
// Reset history with the loaded template
historyActions.reset(mergedTemplate);
setTemplateInfo({
nome: existingTemplate.nome,
descrizione: existingTemplate.descrizione || "",
categoria: existingTemplate.categoria,
});
// Restore selected datasets from template
if (mergedTemplate.dataSources && availableDatasets.length > 0) {
const datasetIds = Object.keys(mergedTemplate.dataSources);
const datasets = availableDatasets.filter((d) =>
datasetIds.includes(d.id),
);
setSelectedDatasets(datasets);
}
} catch (e) {
console.error("Error parsing template:", e);
// In case of error, keep defaultTemplate
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingTemplate, availableDatasets]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async (data: {
template: AprtTemplate;
info: typeof templateInfo;
}) => {
// Update dataSources in template based on selected datasets
const updatedTemplate = {
...data.template,
dataSources: selectedDatasets.reduce(
(acc, ds) => {
acc[ds.id] = { type: "object" as const, schema: ds.id };
return acc;
},
{} as Record<string, { type: "object"; schema: string }>,
),
};
const dto: Partial<ReportTemplateDto> = {
nome: data.info.nome,
descrizione: data.info.descrizione,
categoria: data.info.categoria,
templateJson: JSON.stringify(updatedTemplate),
pageSize: data.template.meta.pageSize,
orientation: data.template.meta.orientation,
attivo: true,
};
if (id) {
return reportTemplateService.update(Number(id), dto);
} else {
return reportTemplateService.create(dto);
}
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["report-templates"] });
setSnackbar({
open: true,
message: "Template salvato con successo",
severity: "success",
});
setSaveDialog(false);
// Mark current state as saved
setLastSavedUndoCount(templateHistory.undoCount);
// Broadcast template to collaborators (instant sync without server reload)
if (collaboration.isConnected && collaboration.currentRoom) {
// Update dataSources in template for broadcasting
const updatedTemplateForSync = {
...template,
dataSources: selectedDatasets.reduce(
(acc, ds) => {
acc[ds.id] = { type: "object" as const, schema: ds.id };
return acc;
},
{} as Record<string, { type: "object"; schema: string }>,
),
};
// Increment version and broadcast
templateVersionRef.current += 1;
const templateJson = JSON.stringify(updatedTemplateForSync);
console.log(
`[AutoSave] Broadcasting template sync, version ${templateVersionRef.current}, size: ${templateJson.length} bytes`,
);
collaboration.broadcastTemplateSync(
templateJson,
templateVersionRef.current,
);
}
if (isNew) {
navigate(`/report-editor/${result.id}`, { replace: true });
}
},
onError: (error) => {
setSnackbar({
open: true,
message: `Errore nel salvataggio: ${error}`,
severity: "error",
});
},
});
// Undo/Redo handlers using the history hook
const handleUndo = useCallback(() => {
historyActions.undo();
}, [historyActions]);
const handleRedo = useCallback(() => {
historyActions.redo();
}, [historyActions]);
// Get selected element(s) - for single selection compatibility, use first selected
const selectedElementId = selectedElementIds[0] || null;
const selectedElement = selectedElementId
? template.elements.find((e) => e.id === selectedElementId)
: null;
const selectedElements = selectedElementIds
.map((id) => template.elements.find((e) => e.id === id))
.filter((e): e is AprtElement => e !== undefined);
// Dataset management
const handleAddDataset = useCallback((dataset: DatasetTypeDto) => {
setSelectedDatasets((prev) => {
if (prev.some((d) => d.id === dataset.id)) return prev;
return [...prev, dataset];
});
}, []);
const handleRemoveDataset = useCallback((datasetId: string) => {
setSelectedDatasets((prev) => prev.filter((d) => d.id !== datasetId));
}, []);
// ============ PAGE MANAGEMENT HANDLERS ============
// Get current page object
const currentPage =
template.pages.find((p) => p.id === currentPageId) || template.pages[0];
const currentPageIndex = template.pages.findIndex(
(p) => p.id === currentPageId,
);
// Get elements for current page only
const currentPageElements = template.elements.filter(
(e) =>
e.pageId === currentPageId ||
(!e.pageId && currentPageId === template.pages[0]?.id),
);
// Add new page
const handleAddPage = useCallback(() => {
const newPageId = `page-${uuidv4().slice(0, 8)}`;
const newPage: AprtPage = {
id: newPageId,
name: `Pagina ${template.pages.length + 1}`,
};
historyActions.set((prev) => ({
...prev,
pages: [...prev.pages, newPage],
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(newPageId, "page", "added", newPage);
}
// Switch to the new page
setCurrentPageId(newPageId);
setSelectedElementIds([]);
}, [template.pages.length, historyActions, collaboration]);
// Duplicate page with all its elements
const handleDuplicatePage = useCallback(
(pageId: string) => {
const sourcePage = template.pages.find((p) => p.id === pageId);
if (!sourcePage) return;
const newPageId = `page-${uuidv4().slice(0, 8)}`;
const newPage: AprtPage = {
...sourcePage,
id: newPageId,
name: `${sourcePage.name} (copia)`,
};
// Duplicate elements from source page
const sourceElements = template.elements.filter(
(e) => e.pageId === pageId,
);
const duplicatedElements: AprtElement[] = sourceElements.map((el) => ({
...el,
id: uuidv4(),
pageId: newPageId,
name: el.name ? `${el.name}_copia` : undefined,
}));
historyActions.set((prev) => ({
...prev,
pages: [...prev.pages, newPage],
elements: [...prev.elements, ...duplicatedElements],
}));
// Switch to the new page
setCurrentPageId(newPageId);
setSelectedElementIds([]);
},
[template.pages, template.elements, historyActions],
);
// Delete page and its elements
const handleDeletePage = useCallback(
(pageId: string) => {
// Don't allow deleting the last page
if (template.pages.length <= 1) return;
const pageIndex = template.pages.findIndex((p) => p.id === pageId);
// Send to collaborators before deleting
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(pageId, "page", "deleted", null);
}
historyActions.set((prev) => ({
...prev,
pages: prev.pages.filter((p) => p.id !== pageId),
elements: prev.elements.filter((e) => e.pageId !== pageId),
}));
// Switch to adjacent page
const newIndex = Math.min(pageIndex, template.pages.length - 2);
const newCurrentPage = template.pages.filter((p) => p.id !== pageId)[
newIndex
];
if (newCurrentPage) {
setCurrentPageId(newCurrentPage.id);
}
setSelectedElementIds([]);
},
[template.pages, historyActions, collaboration],
);
// Rename page
const handleRenamePage = useCallback(
(pageId: string, newName: string) => {
historyActions.set((prev) => ({
...prev,
pages: prev.pages.map((p) =>
p.id === pageId ? { ...p, name: newName } : p,
),
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(pageId, "page", "renamed", newName);
}
},
[historyActions, collaboration],
);
// Move page up or down
const handleMovePage = useCallback(
(pageId: string, direction: "up" | "down") => {
const currentIndex = template.pages.findIndex((p) => p.id === pageId);
if (currentIndex === -1) return;
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (newIndex < 0 || newIndex >= template.pages.length) return;
historyActions.set((prev) => {
const newPages = [...prev.pages];
[newPages[currentIndex], newPages[newIndex]] = [
newPages[newIndex],
newPages[currentIndex],
];
return { ...prev, pages: newPages };
});
},
[template.pages, historyActions],
);
// Select page
const handleSelectPage = useCallback((pageId: string) => {
setCurrentPageId(pageId);
setSelectedElementIds([]); // Clear selection when switching pages
}, []);
// Navigate to previous page
const handlePrevPage = useCallback(() => {
const currentIndex = template.pages.findIndex(
(p) => p.id === currentPageId,
);
if (currentIndex > 0) {
setCurrentPageId(template.pages[currentIndex - 1].id);
setSelectedElementIds([]);
}
}, [template.pages, currentPageId]);
// Navigate to next page
const handleNextPage = useCallback(() => {
const currentIndex = template.pages.findIndex(
(p) => p.id === currentPageId,
);
if (currentIndex < template.pages.length - 1) {
setCurrentPageId(template.pages[currentIndex + 1].id);
setSelectedElementIds([]);
}
}, [template.pages, currentPageId]);
// ============ END PAGE MANAGEMENT HANDLERS ============
// Add new element
const handleAddElement = useCallback(
(type: ElementType) => {
// For images, open the upload dialog instead of creating immediately
if (type === "image") {
setImageUploadDialog(true);
return;
}
const newElement: AprtElement = {
id: uuidv4(),
type,
pageId: currentPageId, // Assign to current page
position: {
x: 20,
y: 20,
width: type === "line" ? 100 : 80,
height: type === "line" ? 1 : type === "table" ? 60 : 20,
},
style: { ...defaultStyle },
content:
type === "text"
? { type: "static", value: "Nuovo testo" }
: undefined,
visible: true,
locked: false,
name: `${type}_${Date.now()}`,
columns:
type === "table"
? [
{
field: "campo1",
header: "Colonna 1",
width: 50,
align: "left",
},
{
field: "campo2",
header: "Colonna 2",
width: 50,
align: "left",
},
]
: undefined,
};
historyActions.set((prev) => ({
...prev,
elements: [...prev.elements, newElement],
}));
setSelectedElementIds([newElement.id]);
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendItemCreated(newElement.id, "element", newElement);
}
// On mobile, open properties panel after adding element
if (isMobile) {
setMobilePanel("properties");
}
},
[historyActions, currentPageId, isMobile, collaboration],
);
// Update element without history (for continuous updates like dragging)
const handleUpdateElementWithoutHistory = useCallback(
(elementId: string, updates: Partial<AprtElement>) => {
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === elementId ? { ...el, ...updates } : el,
),
}));
},
[historyActions],
);
// Update element with history
const handleUpdateElement = useCallback(
(elementId: string, updates: Partial<AprtElement>) => {
historyActions.set((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === elementId ? { ...el, ...updates } : el,
),
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(elementId, "element", "full", updates);
}
},
[historyActions, collaboration],
);
// Handle image selection from dialog
const handleImageSelected = useCallback(
(imageData: ImageData) => {
// Calculate appropriate size based on original dimensions
// Max 100mm width, proportional height
const maxWidth = 100; // mm
const aspectRatio = imageData.originalWidth / imageData.originalHeight;
let width = Math.min(maxWidth, imageData.originalWidth / 3.78); // rough px to mm
let height = width / aspectRatio;
// Ensure minimum size
if (width < 20) width = 20;
if (height < 20) height = 20;
// Check if we're replacing an existing image element
if (selectedElementId) {
const existingElement = template.elements.find(
(e) => e.id === selectedElementId && e.type === "image",
);
if (existingElement) {
// Update existing image element
handleUpdateElement(selectedElementId, {
imageSettings: {
...defaultImageSettings,
...existingElement.imageSettings,
src: imageData.src,
originalWidth: imageData.originalWidth,
originalHeight: imageData.originalHeight,
},
});
return;
}
}
// Create new image element
const newElement: AprtElement = {
id: uuidv4(),
type: "image",
pageId: currentPageId, // Assign to current page
position: {
x: 20,
y: 20,
width: Math.round(width),
height: Math.round(height),
},
style: { ...defaultStyle },
imageSettings: {
...defaultImageSettings,
src: imageData.src,
originalWidth: imageData.originalWidth,
originalHeight: imageData.originalHeight,
},
visible: true,
locked: false,
name: imageData.fileName || `image_${Date.now()}`,
};
historyActions.set((prev) => ({
...prev,
elements: [...prev.elements, newElement],
}));
setSelectedElementIds([newElement.id]);
},
[
selectedElementId,
template.elements,
handleUpdateElement,
historyActions,
currentPageId,
],
);
// Update selected element (with history)
const handleUpdateSelectedElement = useCallback(
(updates: Partial<AprtElement>) => {
if (!selectedElementId) return;
handleUpdateElement(selectedElementId, updates);
},
[selectedElementId, handleUpdateElement],
);
// Delete element
const handleDeleteElement = useCallback(() => {
if (!selectedElementId) return;
// Send to collaborators before deleting
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendItemDeleted(selectedElementId, "element");
}
historyActions.set((prev) => ({
...prev,
elements: prev.elements.filter((el) => el.id !== selectedElementId),
}));
setSelectedElementIds([]);
}, [selectedElementId, historyActions, collaboration]);
// Copy element
const handleCopyElement = useCallback(() => {
if (!selectedElement) return;
const copy: AprtElement = {
...selectedElement,
id: uuidv4(),
name: `${selectedElement.name}_copia`,
position: {
...selectedElement.position,
x: selectedElement.position.x + 10,
y: selectedElement.position.y + 10,
},
};
historyActions.set((prev) => ({
...prev,
elements: [...prev.elements, copy],
}));
setSelectedElementIds([copy.id]);
}, [selectedElement, historyActions]);
// Toggle lock
const handleToggleLock = useCallback(() => {
if (!selectedElementId) return;
handleUpdateElement(selectedElementId, {
locked: !selectedElement?.locked,
});
}, [selectedElementId, selectedElement, handleUpdateElement]);
// Update page settings
const handleUpdatePage = useCallback(
(updates: {
pageSize?: PageSize;
orientation?: PageOrientation;
margins?: AprtMargins;
}) => {
historyActions.set((prev) => ({
...prev,
meta: {
...prev.meta,
...(updates.pageSize && { pageSize: updates.pageSize }),
...(updates.orientation && { orientation: updates.orientation }),
...(updates.margins && { margins: updates.margins }),
},
}));
},
[historyActions],
);
// Insert binding into selected text element
const handleInsertBinding = useCallback(
(binding: string) => {
if (!selectedElement || selectedElement.type !== "text") return;
const currentValue =
selectedElement.content?.value ||
selectedElement.content?.expression ||
"";
const newContent = {
...selectedElement.content,
type: "binding" as const,
expression: currentValue + binding,
};
handleUpdateElement(selectedElement.id, { content: newContent });
},
[selectedElement, handleUpdateElement],
);
// ============ CONTEXT MENU HANDLERS ============
// Handle context menu event from canvas
const handleContextMenu = useCallback((event: ContextMenuEvent) => {
if (event.elementId) {
setSelectedElementIds(event.elementId ? [event.elementId] : []);
}
setContextMenu({
open: true,
position: event.position,
});
}, []);
const closeContextMenu = useCallback(() => {
setContextMenu({ open: false, position: null });
}, []);
// Cut element (copy + delete)
const handleCut = useCallback(() => {
if (!selectedElement) return;
setClipboard({ ...selectedElement });
handleDeleteElement();
}, [selectedElement, handleDeleteElement]);
// Copy to clipboard
const handleCopy = useCallback(() => {
if (!selectedElement) return;
setClipboard({ ...selectedElement });
setSnackbar({
open: true,
message: "Elemento copiato",
severity: "success",
});
}, [selectedElement]);
// Paste from clipboard
const handlePaste = useCallback(() => {
if (!clipboard) return;
const pastedElement: AprtElement = {
...clipboard,
id: uuidv4(),
name: `${clipboard.name}_incollato`,
position: {
...clipboard.position,
x: clipboard.position.x + 10,
y: clipboard.position.y + 10,
},
};
historyActions.set((prev) => ({
...prev,
elements: [...prev.elements, pastedElement],
}));
setSelectedElementIds([pastedElement.id]);
}, [clipboard, historyActions]);
// Duplicate element
const handleDuplicate = useCallback(() => {
handleCopyElement();
}, [handleCopyElement]);
// Layer operations
const handleBringToFront = useCallback(() => {
if (!selectedElementId) return;
historyActions.set((prev) => {
const elements = [...prev.elements];
const idx = elements.findIndex((e) => e.id === selectedElementId);
if (idx === -1 || idx === elements.length - 1) return prev;
const [element] = elements.splice(idx, 1);
elements.push(element);
return { ...prev, elements };
});
}, [selectedElementId, historyActions]);
const handleSendToBack = useCallback(() => {
if (!selectedElementId) return;
historyActions.set((prev) => {
const elements = [...prev.elements];
const idx = elements.findIndex((e) => e.id === selectedElementId);
if (idx === -1 || idx === 0) return prev;
const [element] = elements.splice(idx, 1);
elements.unshift(element);
return { ...prev, elements };
});
}, [selectedElementId, historyActions]);
const handleBringForward = useCallback(() => {
if (!selectedElementId) return;
historyActions.set((prev) => {
const elements = [...prev.elements];
const idx = elements.findIndex((e) => e.id === selectedElementId);
if (idx === -1 || idx === elements.length - 1) return prev;
[elements[idx], elements[idx + 1]] = [elements[idx + 1], elements[idx]];
return { ...prev, elements };
});
}, [selectedElementId, historyActions]);
const handleSendBackward = useCallback(() => {
if (!selectedElementId) return;
historyActions.set((prev) => {
const elements = [...prev.elements];
const idx = elements.findIndex((e) => e.id === selectedElementId);
if (idx === -1 || idx === 0) return prev;
[elements[idx], elements[idx - 1]] = [elements[idx - 1], elements[idx]];
return { ...prev, elements };
});
}, [selectedElementId, historyActions]);
// Get page dimensions for alignment
const getPageDimensionsMm = useCallback(() => {
const sizes: Record<string, { width: number; height: number }> = {
A4: { width: 210, height: 297 },
A3: { width: 297, height: 420 },
Letter: { width: 216, height: 279 },
Legal: { width: 216, height: 356 },
};
const size = sizes[template.meta.pageSize] || sizes.A4;
return template.meta.orientation === "landscape"
? { width: size.height, height: size.width }
: size;
}, [template.meta.pageSize, template.meta.orientation]);
// Alignment operations
const handleAlignLeft = useCallback(() => {
if (!selectedElement) return;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
x: template.meta.margins.left,
},
});
}, [
selectedElement,
selectedElementId,
template.meta.margins.left,
handleUpdateElement,
]);
const handleAlignCenter = useCallback(() => {
if (!selectedElement) return;
const pageDims = getPageDimensionsMm();
const centerX = (pageDims.width - selectedElement.position.width) / 2;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
x: centerX,
},
});
}, [
selectedElement,
selectedElementId,
getPageDimensionsMm,
handleUpdateElement,
]);
const handleAlignRight = useCallback(() => {
if (!selectedElement) return;
const pageDims = getPageDimensionsMm();
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
x:
pageDims.width -
template.meta.margins.right -
selectedElement.position.width,
},
});
}, [
selectedElement,
selectedElementId,
getPageDimensionsMm,
template.meta.margins.right,
handleUpdateElement,
]);
const handleAlignTop = useCallback(() => {
if (!selectedElement) return;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
y: template.meta.margins.top,
},
});
}, [
selectedElement,
selectedElementId,
template.meta.margins.top,
handleUpdateElement,
]);
const handleAlignMiddle = useCallback(() => {
if (!selectedElement) return;
const pageDims = getPageDimensionsMm();
const centerY = (pageDims.height - selectedElement.position.height) / 2;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
y: centerY,
},
});
}, [
selectedElement,
selectedElementId,
getPageDimensionsMm,
handleUpdateElement,
]);
const handleAlignBottom = useCallback(() => {
if (!selectedElement) return;
const pageDims = getPageDimensionsMm();
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
y:
pageDims.height -
template.meta.margins.bottom -
selectedElement.position.height,
},
});
}, [
selectedElement,
selectedElementId,
getPageDimensionsMm,
template.meta.margins.bottom,
handleUpdateElement,
]);
const handleCenterOnPage = useCallback(() => {
if (!selectedElement) return;
const pageDims = getPageDimensionsMm();
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
x: (pageDims.width - selectedElement.position.width) / 2,
y: (pageDims.height - selectedElement.position.height) / 2,
},
});
}, [
selectedElement,
selectedElementId,
getPageDimensionsMm,
handleUpdateElement,
]);
// Transform operations
const handleRotateLeft = useCallback(() => {
if (!selectedElement) return;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
rotation: ((selectedElement.position.rotation || 0) - 90) % 360,
},
});
}, [selectedElement, selectedElementId, handleUpdateElement]);
const handleRotateRight = useCallback(() => {
if (!selectedElement) return;
handleUpdateElement(selectedElementId!, {
position: {
...selectedElement.position,
rotation: ((selectedElement.position.rotation || 0) + 90) % 360,
},
});
}, [selectedElement, selectedElementId, handleUpdateElement]);
const handleFlipHorizontal = useCallback(() => {
if (!selectedElement || selectedElement.type !== "image") return;
handleUpdateElement(selectedElementId!, {
imageSettings: {
...defaultImageSettings,
...selectedElement.imageSettings,
flipHorizontal: !selectedElement.imageSettings?.flipHorizontal,
},
});
}, [selectedElement, selectedElementId, handleUpdateElement]);
const handleFlipVertical = useCallback(() => {
if (!selectedElement || selectedElement.type !== "image") return;
handleUpdateElement(selectedElementId!, {
imageSettings: {
...defaultImageSettings,
...selectedElement.imageSettings,
flipVertical: !selectedElement.imageSettings?.flipVertical,
},
});
}, [selectedElement, selectedElementId, handleUpdateElement]);
// Group operations (placeholder - tables are treated as groups)
const handleGroup = useCallback(() => {
setSnackbar({
open: true,
message: "Raggruppamento non ancora implementato",
severity: "error",
});
}, []);
const handleUngroup = useCallback(() => {
setSnackbar({
open: true,
message: "Separazione non ancora implementata",
severity: "error",
});
}, []);
// Selection operations
const handleSelectAll = useCallback(() => {
// Select all elements on the current page
const pageElementIds = currentPageElements.map((e) => e.id);
if (pageElementIds.length > 0) {
setSelectedElementIds(pageElementIds);
}
}, [currentPageElements]);
const handleDeselectAll = useCallback(() => {
setSelectedElementIds([]);
}, []);
// Toggle visibility
const handleToggleVisibility = useCallback(() => {
if (!selectedElement) return;
handleUpdateElement(selectedElementId!, {
visible: !selectedElement.visible,
});
}, [selectedElement, selectedElementId, handleUpdateElement]);
// Unlock element
const handleUnlock = useCallback(() => {
if (!selectedElementId) return;
handleUpdateElement(selectedElementId, { locked: false });
}, [selectedElementId, handleUpdateElement]);
// Edit text (focus on element for editing)
const handleEditText = useCallback(() => {
// This would need canvas ref to trigger text editing
// For now, just select the element
setSnackbar({
open: true,
message: "Fai doppio click sul testo per modificarlo",
severity: "success",
});
}, []);
// Replace image
const handleReplaceImage = useCallback(() => {
if (selectedElement?.type === "image") {
setImageUploadDialog(true);
}
}, [selectedElement]);
// Fit to content (for text elements)
const handleFitToContent = useCallback(() => {
// This would need to calculate text dimensions
setSnackbar({
open: true,
message: "Adatta al contenuto non ancora implementato",
severity: "error",
});
}, []);
// ============ END CONTEXT MENU HANDLERS ============
// Save template
const handleSave = useCallback(() => {
if (isNew) {
setSaveDialog(true);
} else {
saveMutation.mutate({ template, info: templateInfo });
}
}, [isNew, template, templateInfo, saveMutation]);
// Preview PDF
const handlePreview = useCallback(() => {
if (selectedDatasets.length === 0) {
setSnackbar({
open: true,
message: "Seleziona almeno un dataset per l'anteprima",
severity: "error",
});
return;
}
setPreviewDialog(true);
}, [selectedDatasets]);
// Generate preview with selected data
const handleGeneratePreview = useCallback(
async (dataSources: DataSourceSelection[]) => {
try {
setIsGeneratingPreview(true);
// Save template first if new
if (isNew) {
setSnackbar({
open: true,
message: "Salva il template prima di visualizzare l'anteprima",
severity: "error",
});
setPreviewDialog(false);
setIsGeneratingPreview(false);
return;
}
const blob = await reportGeneratorService.preview({
templateId: Number(id),
dataSources,
});
openBlobInNewTab(blob);
setPreviewDialog(false);
} catch (error) {
setSnackbar({
open: true,
message: `Errore nella generazione dell'anteprima: ${error}`,
severity: "error",
});
} finally {
setIsGeneratingPreview(false);
}
},
[id, isNew],
);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts when typing in input fields
const target = e.target as HTMLElement;
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
// Don't handle shortcuts when editing text in canvas
const isCanvasTextEditing = canvasRef.current?.isTextEditing() ?? false;
// Allow browser defaults for input fields and text editing
if (isInputField || isCanvasTextEditing) {
return;
}
const hasCtrl = e.ctrlKey || e.metaKey;
const hasShift = e.shiftKey;
// Handle Ctrl/Cmd shortcuts
if (hasCtrl) {
switch (e.key.toLowerCase()) {
// Undo: Ctrl+Z
case "z":
if (!hasShift) {
e.preventDefault();
handleUndo();
}
break;
// Redo: Ctrl+Y or Ctrl+Shift+Z
case "y":
e.preventDefault();
handleRedo();
break;
// Save: Ctrl+S
case "s":
e.preventDefault();
handleSave();
break;
// Cut: Ctrl+X
case "x":
if (selectedElementId) {
e.preventDefault();
handleCut();
}
break;
// Copy: Ctrl+C
case "c":
if (selectedElementId) {
e.preventDefault();
handleCopy();
}
break;
// Paste: Ctrl+V
case "v":
if (clipboard) {
e.preventDefault();
handlePaste();
}
break;
// Duplicate: Ctrl+D
case "d":
if (selectedElementId) {
e.preventDefault();
handleDuplicate();
}
break;
// Select All: Ctrl+A
case "a":
e.preventDefault();
handleSelectAll();
break;
// Lock/Unlock: Ctrl+L
case "l":
if (selectedElementId) {
e.preventDefault();
handleToggleLock();
}
break;
// Group: Ctrl+G / Ungroup: Ctrl+Shift+G
case "g":
if (selectedElementId) {
e.preventDefault();
if (hasShift) {
handleUngroup();
} else {
handleGroup();
}
}
break;
// Layer ordering with brackets
// Bring Forward: Ctrl+] / Bring to Front: Ctrl+Shift+]
case "]":
if (selectedElementId) {
e.preventDefault();
if (hasShift) {
handleBringToFront();
} else {
handleBringForward();
}
}
break;
// Send Backward: Ctrl+[ / Send to Back: Ctrl+Shift+[
case "[":
if (selectedElementId) {
e.preventDefault();
if (hasShift) {
handleSendToBack();
} else {
handleSendBackward();
}
}
break;
}
}
// Non-Ctrl shortcuts
if (!hasCtrl) {
switch (e.key) {
// Delete element
case "Delete":
case "Backspace":
if (selectedElementId && !selectedElement?.locked) {
e.preventDefault();
handleDeleteElement();
}
break;
// Escape to deselect
case "Escape":
e.preventDefault();
handleDeselectAll();
break;
// Page navigation
case "PageUp":
e.preventDefault();
handlePrevPage();
break;
case "PageDown":
e.preventDefault();
handleNextPage();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
handleUndo,
handleRedo,
handleSave,
handleCut,
handleCopy,
handlePaste,
handleDuplicate,
handleSelectAll,
handleDeselectAll,
handleToggleLock,
handleGroup,
handleUngroup,
handleBringToFront,
handleBringForward,
handleSendToBack,
handleSendBackward,
handleDeleteElement,
handlePrevPage,
handleNextPage,
selectedElementId,
selectedElement,
clipboard,
]);
// Auto-save: simple debounced save on every template change
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveMutationRef = useRef(saveMutation);
saveMutationRef.current = saveMutation;
const templateForSaveRef = useRef(template);
templateForSaveRef.current = template;
const templateInfoForSaveRef = useRef(templateInfo);
templateInfoForSaveRef.current = templateInfo;
useEffect(() => {
// Skip if disabled or new template
if (!autoSaveEnabled || isNew) {
return;
}
// Skip if no unsaved changes
if (!hasUnsavedChanges) {
return;
}
// Clear previous timeout (debounce)
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
// Save after 500ms debounce
autoSaveTimeoutRef.current = setTimeout(() => {
// Check if not already saving at the moment of execution
if (!saveMutationRef.current.isPending) {
console.log("[AutoSave] Saving...");
saveMutationRef.current.mutate({
template: templateForSaveRef.current,
info: templateInfoForSaveRef.current,
});
}
}, 500);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [autoSaveEnabled, isNew, hasUnsavedChanges, templateHistory.undoCount]);
if (isLoadingTemplate && id) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="400px"
>
<CircularProgress />
</Box>
);
}
// Render panels based on screen size
// embedded=true when used inside CollapsiblePanel (removes internal borders/headers)
const renderPageNavigator = (embedded = false) => (
<PageNavigator
pages={template.pages}
elements={template.elements}
currentPageId={currentPageId}
onSelectPage={handleSelectPage}
onAddPage={handleAddPage}
onDuplicatePage={handleDuplicatePage}
onDeletePage={handleDeletePage}
onRenamePage={handleRenamePage}
onMovePage={handleMovePage}
embedded={embedded}
/>
);
const renderDataBindingPanel = (embedded = false) => (
<DataBindingPanel
schemas={schemas}
selectedDatasets={selectedDatasets}
onInsertBinding={handleInsertBinding}
onRemoveDataset={handleRemoveDataset}
embedded={embedded}
/>
);
const renderPropertiesPanel = (embedded = false) => (
<PropertiesPanel
element={selectedElement || null}
onUpdateElement={handleUpdateSelectedElement}
pageSize={(currentPage?.pageSize as PageSize) || template.meta.pageSize}
orientation={
(currentPage?.orientation as PageOrientation) ||
template.meta.orientation
}
margins={currentPage?.margins || template.meta.margins}
onUpdatePage={handleUpdatePage}
fontFamilies={fontFamilies}
availableDatasets={availableDatasets}
dataSchemas={dataSchemaMap}
onOpenImageUpload={() => setImageUploadDialog(true)}
// Page-specific props
currentPage={currentPage}
onUpdateCurrentPage={(updates) => {
if (!currentPage) return;
historyActions.set((prev) => ({
...prev,
pages: prev.pages.map((p) =>
p.id === currentPage.id ? { ...p, ...updates } : p,
),
}));
}}
embedded={embedded}
/>
);
// Mobile drawer content
const renderMobileDrawerContent = () => {
switch (mobilePanel) {
case "pages":
return renderPageNavigator();
case "data":
return renderDataBindingPanel();
case "properties":
return renderPropertiesPanel();
default:
return null;
}
};
const getMobilePanelTitle = () => {
switch (mobilePanel) {
case "pages":
return "Pagine";
case "data":
return "Campi Dati";
case "properties":
return "Proprietà";
default:
return "";
}
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: { xs: "calc(100vh - 56px)", sm: "calc(100vh - 64px)" },
mx: { xs: -1.5, sm: -2, md: -3 },
mt: { xs: -1.5, sm: -2, md: -3 },
overflow: "hidden",
}}
>
{/* Dataset Selector - hide on mobile, show in compact mode on tablet */}
{!isMobile && (
<DatasetSelector
availableDatasets={availableDatasets}
selectedDatasets={selectedDatasets}
onAddDataset={handleAddDataset}
onRemoveDataset={handleRemoveDataset}
onOpenDatasetManager={() => setDatasetManagerDialog(true)}
/>
)}
{/* Toolbar */}
<EditorToolbar
onAddElement={handleAddElement}
onDeleteElement={handleDeleteElement}
onCopyElement={handleCopyElement}
onToggleLock={handleToggleLock}
zoom={zoom}
onZoomChange={setZoom}
showGrid={showGrid}
onToggleGrid={() => setShowGrid(!showGrid)}
snapOptions={snapOptions}
onSnapOptionsChange={setSnapOptions}
canUndo={templateHistory.canUndo}
canRedo={templateHistory.canRedo}
onUndo={handleUndo}
onRedo={handleRedo}
onSave={handleSave}
onPreview={handlePreview}
hasSelection={!!selectedElementId}
isLocked={selectedElement?.locked || false}
isSaving={saveMutation.isPending}
currentPageIndex={currentPageIndex}
totalPages={template.pages.length}
currentPageName={currentPage?.name || "Pagina 1"}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
hasUnsavedChanges={hasUnsavedChanges}
// Auto-save props
autoSaveEnabled={autoSaveEnabled}
onAutoSaveToggle={setAutoSaveEnabled}
/>
{/* Main Editor Area */}
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* 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>
)}
{/* 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 */}
<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>
{/* 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 */}
{isMobile && (
<Paper
sx={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 1100,
borderTop: 1,
borderColor: "divider",
}}
elevation={3}
>
<BottomNavigation
value={mobilePanel}
onChange={(_, newValue) => {
setMobilePanel(newValue === mobilePanel ? null : newValue);
}}
showLabels
>
<BottomNavigationAction
label="Pagine"
value="pages"
icon={<PageIcon />}
/>
<BottomNavigationAction
label="Dati"
value="data"
icon={<DataIcon />}
/>
<BottomNavigationAction
label="Proprietà"
value="properties"
icon={<SettingsIcon />}
/>
</BottomNavigation>
</Paper>
)}
{/* Mobile Panel Drawer */}
<SwipeableDrawer
anchor="bottom"
open={isMobile && mobilePanel !== null}
onClose={() => setMobilePanel(null)}
onOpen={() => {}}
disableSwipeToOpen
PaperProps={{
sx: {
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
{/* Drawer Header */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="h6">{getMobilePanelTitle()}</Typography>
<IconButton onClick={() => setMobilePanel(null)}>
<CloseIcon />
</IconButton>
</Box>
{/* Drawer Content */}
<Box sx={{ flex: 1, overflow: "auto" }}>
{renderMobileDrawerContent()}
</Box>
</Box>
</SwipeableDrawer>
{/* Save Dialog for new templates */}
<Dialog
open={saveDialog}
onClose={() => setSaveDialog(false)}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
>
<DialogTitle>Salva Template</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} mt={1}>
<TextField
label="Nome"
value={templateInfo.nome}
onChange={(e) =>
setTemplateInfo((prev) => ({ ...prev, nome: e.target.value }))
}
fullWidth
required
/>
<TextField
label="Descrizione"
value={templateInfo.descrizione}
onChange={(e) =>
setTemplateInfo((prev) => ({
...prev,
descrizione: e.target.value,
}))
}
fullWidth
multiline
rows={2}
/>
<FormControl fullWidth>
<InputLabel>Categoria</InputLabel>
<Select
value={templateInfo.categoria}
label="Categoria"
onChange={(e) =>
setTemplateInfo((prev) => ({
...prev,
categoria: e.target.value,
}))
}
>
<MenuItem value="Generale">Generale</MenuItem>
<MenuItem value="Evento">Evento</MenuItem>
<MenuItem value="Cliente">Cliente</MenuItem>
<MenuItem value="Articoli">Articoli</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button onClick={() => setSaveDialog(false)} fullWidth={isMobile}>
Annulla
</Button>
<Button
variant="contained"
onClick={() =>
saveMutation.mutate({ template, info: templateInfo })
}
disabled={!templateInfo.nome || saveMutation.isPending}
fullWidth={isMobile}
>
{saveMutation.isPending ? "Salvataggio..." : "Salva"}
</Button>
</DialogActions>
</Dialog>
{/* Preview Dialog */}
<PreviewDialog
open={previewDialog}
onClose={() => setPreviewDialog(false)}
selectedDatasets={selectedDatasets}
onGeneratePreview={handleGeneratePreview}
isGenerating={isGeneratingPreview}
/>
{/* Dataset Manager Dialog */}
<DatasetManagerDialog
open={datasetManagerDialog}
onClose={() => setDatasetManagerDialog(false)}
onDatasetCreated={() => {
// Refresh both queries - available datasets now includes virtual datasets
queryClient.invalidateQueries({ queryKey: ["available-datasets"] });
queryClient.invalidateQueries({ queryKey: ["virtual-datasets"] });
}}
/>
{/* Image Upload Dialog */}
<ImageUploadDialog
open={imageUploadDialog}
onClose={() => setImageUploadDialog(false)}
onImageSelected={handleImageSelected}
/>
{/* Context Menu */}
<ContextMenu
open={contextMenu.open}
position={contextMenu.position}
selectedElement={selectedElement || null}
selectedElements={selectedElements}
hasClipboard={clipboard !== null}
onClose={closeContextMenu}
// Clipboard actions
onCopy={handleCopy}
onCut={handleCut}
onPaste={handlePaste}
onDuplicate={handleDuplicate}
onDelete={handleDeleteElement}
// Lock actions
onLock={handleToggleLock}
onUnlock={handleUnlock}
// Layer actions
onBringToFront={handleBringToFront}
onSendToBack={handleSendToBack}
onBringForward={handleBringForward}
onSendBackward={handleSendBackward}
// Alignment actions
onAlignLeft={handleAlignLeft}
onAlignCenter={handleAlignCenter}
onAlignRight={handleAlignRight}
onAlignTop={handleAlignTop}
onAlignMiddle={handleAlignMiddle}
onAlignBottom={handleAlignBottom}
onCenterOnPage={handleCenterOnPage}
// Transform actions
onRotateLeft={handleRotateLeft}
onRotateRight={handleRotateRight}
onFlipHorizontal={handleFlipHorizontal}
onFlipVertical={handleFlipVertical}
// Group actions
onGroup={handleGroup}
onUngroup={handleUngroup}
// Selection actions
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
// Visibility
onToggleVisibility={handleToggleVisibility}
// Type-specific actions
onEditText={
selectedElement?.type === "text" ? handleEditText : undefined
}
onReplaceImage={
selectedElement?.type === "image" ? handleReplaceImage : undefined
}
onFitToContent={
selectedElement?.type === "text" ? handleFitToContent : undefined
}
/>
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((prev) => ({ ...prev, open: false }))}
>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar((prev) => ({ ...prev, open: false }))}
>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}