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( 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( [], ); // Page state - track which page is currently being edited const [currentPageId, setCurrentPageId] = useState(defaultPage.id); // Editor state - support multiple selection const [selectedElementIds, setSelectedElementIds] = useState([]); const [zoom, setZoom] = useState(isMobile ? 0.5 : 1); const [showGrid, setShowGrid] = useState(true); const [snapOptions, setSnapOptions] = useState({ grid: false, objects: true, borders: true, center: true, tangent: true, }); const [gridSize] = useState(5); // 5mm grid // Mobile panel state const [mobilePanel, setMobilePanel] = useState(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(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(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) } : 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) } : 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), 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, ); // 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; // 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, ), }; const dto: Partial = { 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, ), }; // 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) => { 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) => { 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) => { 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 = { 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 | 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 ( ); } // Render panels based on screen size // embedded=true when used inside CollapsiblePanel (removes internal borders/headers) const renderPageNavigator = (embedded = false) => ( ); const renderDataBindingPanel = (embedded = false) => ( ); const renderPropertiesPanel = (embedded = false) => ( 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 ( {/* Dataset Selector - hide on mobile, show in compact mode on tablet */} {!isMobile && ( setDatasetManagerDialog(true)} /> )} {/* Toolbar */} 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 */} {/* Left Sidebar - Panels on left side */} {!isMobile && ( {panelLayout.getPanelsForPosition("left").map((panelState) => { if (panelState.id === "pages") { return ( } 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)} ); } if (panelState.id === "data") { return ( } 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)} ); } if (panelState.id === "properties") { return ( } 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)} ); } return null; })} )} {/* Center Area - Canvas Container (flex: 1 to take remaining space, centers the canvas) */} theme.palette.mode === "dark" ? "#1a1a1a" : "#e0e0e0", position: "relative", }} > {/* Canvas - show only elements for current page */} { 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} /> {/* Right Sidebar - Panels on right side */} {!isMobile && ( {panelLayout.getPanelsForPosition("right").map((panelState) => { if (panelState.id === "pages") { return ( } 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)} ); } if (panelState.id === "data") { return ( } 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)} ); } if (panelState.id === "properties") { return ( } 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)} ); } return null; })} )} {/* Mobile Bottom Navigation */} {isMobile && ( { setMobilePanel(newValue === mobilePanel ? null : newValue); }} showLabels > } /> } /> } /> )} {/* Mobile Panel Drawer */} setMobilePanel(null)} onOpen={() => {}} disableSwipeToOpen PaperProps={{ sx: { height: "70vh", borderTopLeftRadius: 16, borderTopRightRadius: 16, }, }} > {/* Drawer Header */} {getMobilePanelTitle()} setMobilePanel(null)}> {/* Drawer Content */} {renderMobileDrawerContent()} {/* Save Dialog for new templates */} setSaveDialog(false)} maxWidth="sm" fullWidth fullScreen={isMobile} > Salva Template setTemplateInfo((prev) => ({ ...prev, nome: e.target.value })) } fullWidth required /> setTemplateInfo((prev) => ({ ...prev, descrizione: e.target.value, })) } fullWidth multiline rows={2} /> Categoria {/* Preview Dialog */} setPreviewDialog(false)} selectedDatasets={selectedDatasets} onGeneratePreview={handleGeneratePreview} isGenerating={isGeneratingPreview} /> {/* Dataset Manager Dialog */} 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 */} setImageUploadDialog(false)} onImageSelected={handleImageSelected} /> {/* Context Menu */} {/* Snackbar */} setSnackbar((prev) => ({ ...prev, open: false }))} > setSnackbar((prev) => ({ ...prev, open: false }))} > {snackbar.message} ); }