2375 lines
72 KiB
TypeScript
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>
|
|
);
|
|
}
|