import { createContext, useContext, useReducer, ReactNode, useEffect, useRef, Dispatch, } from "react"; import { useCanvasSettings } from "../CanvasSettingsContext"; import { BuilderState, BuilderAction, CanvasState, SelectionState, DragState, Element, BuilderMode, HistoryState, TemplateState, } from "../../types/elements"; import { debugWarn } from "../../utils/debug"; // Type pour les propriétés des éléments product_table interface ProductTableProperties { showHeaders: boolean; showBorders: boolean; showAlternatingRows: boolean; showSku: boolean; showDescription: boolean; showQuantity: boolean; fontSize: number; currency: string; tableStyle: string; textAlign: string; verticalAlign: string; backgroundColor: string; headerBackgroundColor: string; headerTextColor: string; alternateRowColor: string; borderColor: string; textColor: string; columns?: Record; } // Fonction helper pour corriger les positions des éléments hors limites // ✅ BUGFIX-014: Accept canvas dimensions as parameters for dynamic sizing const clampElementPositions = ( elements: Element[], canvasWidth: number = 794, canvasHeight: number = 1123, ): Element[] => { return elements.map((element) => { let newX = element.x; let newY = element.y; // Clamp X position (laisser au moins 5px visible) const minVisibleWidth = Math.min(15, element.width * 0.3); if (newX < 0) newX = 0; if (newX + minVisibleWidth > canvasWidth) newX = Math.max(0, canvasWidth - minVisibleWidth); // Clamp Y position (laisser au moins 5px visible) const minVisibleHeight = Math.min(10, element.height * 0.3); if (newY < 0) newY = 0; if (newY + minVisibleHeight > canvasHeight) newY = Math.max(0, canvasHeight - minVisibleHeight); if (newX !== element.x || newY !== element.y) { return { ...element, x: newX, y: newY }; } return element; }); }; // Fonction helper pour réparer les propriétés des éléments product_table const repairProductTableProperties = (elements: Element[]): Element[] => { const defaultProperties: ProductTableProperties = { // Fonctionnalités de base showHeaders: true, showBorders: true, showAlternatingRows: true, showSku: true, showDescription: true, showQuantity: true, // Style et apparence fontSize: 11, currency: "€", tableStyle: "default", // Alignements textAlign: "left", verticalAlign: "top", // Couleurs backgroundColor: "#ffffff", headerBackgroundColor: "#f9fafb", headerTextColor: "#111827", alternateRowColor: "#f9fafb", borderColor: "#e5e7eb", textColor: "#374151", }; const defaultColumns = { image: true, name: true, quantity: true, price: true, total: true, }; return elements.map((element) => { if (element.type !== "product_table") return element; const repairedElement: any = { ...element }; // ✅ Ajouter les colonnes manquantes avec défauts if ( !repairedElement.columns || typeof repairedElement.columns !== "object" ) { (repairedElement as unknown as Record).columns = { ...defaultColumns, }; } else { // Remplir les colonnes manquantes avec les défauts Object.keys(defaultColumns).forEach((col) => { if (!(col in (repairedElement.columns || {}))) { (repairedElement.columns as Record)[col] = defaultColumns[col as keyof typeof defaultColumns]; } }); } // Ajouter les propriétés manquantes Object.keys(defaultProperties).forEach((prop) => { if (!(prop in repairedElement)) { (repairedElement as Record)[prop] = defaultProperties[prop as keyof ProductTableProperties]; } }); // Validation des booléens const booleanProps: (keyof ProductTableProperties)[] = [ "showHeaders", "showBorders", "showAlternatingRows", "showSku", "showDescription", "showQuantity", ]; booleanProps.forEach((prop) => { if (typeof repairedElement[prop] !== "boolean") { (repairedElement as Record)[prop] = defaultProperties[prop]; } }); // Validation des nombres const numberProps: (keyof ProductTableProperties)[] = ["fontSize"]; numberProps.forEach((prop) => { if (typeof repairedElement[prop] !== "number") { (repairedElement as Record)[prop] = defaultProperties[prop]; } }); // Validation des alignements const validHorizontalAligns = ["left", "center", "right"]; if (!validHorizontalAligns.includes(repairedElement.textAlign as string)) { repairedElement.textAlign = defaultProperties.textAlign; } const validVerticalAligns = ["top", "middle", "bottom"]; if ( !validVerticalAligns.includes(repairedElement.verticalAlign as string) ) { repairedElement.verticalAlign = defaultProperties.verticalAlign; } // Validation des couleurs (format hexadécimal) const colorProperties: (keyof ProductTableProperties)[] = [ "backgroundColor", "headerBackgroundColor", "alternateRowColor", "borderColor", "headerTextColor", "textColor", ]; colorProperties.forEach((prop) => { if ( repairedElement[prop] && !/^#[0-9A-Fa-f]{6}$/.test(repairedElement[prop] as string) ) { (repairedElement as Record)[prop] = defaultProperties[prop]; } }); // Validation de la devise if ( !repairedElement.currency || typeof repairedElement.currency !== "string" ) { repairedElement.currency = defaultProperties.currency; } return repairedElement; }); }; // État initial const initialCanvasState: CanvasState = { zoom: 100, pan: { x: 0, y: 0 }, showGrid: false, gridSize: 20, snapToGrid: false, // Désactivé par défaut pour les utilisateurs gratuits backgroundColor: "#ffffff", }; const initialSelectionState: SelectionState = { selectedElements: [], isSelecting: false, }; const initialDragState: DragState = { isDragging: false, draggedElements: [], }; const initialHistoryState: HistoryState = { past: [], present: { elements: [], canvas: initialCanvasState, selection: initialSelectionState, drag: initialDragState, mode: "select", template: { isNew: true, isModified: false, isSaving: false, isLoading: true, // ✅ Start as loading }, previewMode: "editor", history: { past: [], present: null as any, // Évite la récursion infinie future: [], canUndo: false, canRedo: false, }, } as BuilderState, future: [], canUndo: false, canRedo: false, }; const initialState: BuilderState = { elements: [], canvas: initialCanvasState, selection: initialSelectionState, drag: initialDragState, mode: "select", template: { isNew: true, isModified: false, isSaving: false, isLoading: true, // ✅ Start as loading until template is fetched name: "Nouveau template", description: "", tags: [], canvasWidth: 794, // A4 width in PX (portrait) canvasHeight: 1123, // A4 height in PX marginTop: 28, // ~10mm in PX marginBottom: 28, // ~10mm in PX showGuides: false, // Désactivé par défaut pour les utilisateurs gratuits snapToGrid: false, }, previewMode: "editor", history: initialHistoryState, showPreviewModal: false, htmlPreviewContent: "", }; // Reducer function builderReducer( state: BuilderState, action: BuilderAction, ): BuilderState { switch (action.type) { case "ADD_ELEMENT": { const element = action.payload; return { ...state, elements: [...state.elements, element], template: { ...state.template, isModified: true, }, history: updateHistory(state, { ...state, elements: [...state.elements, element], }), }; } case "UPDATE_ELEMENT": { // ✅ BUGFIX-003: Comprehensive property preservation // Ensure ALL existing properties are retained when updating const updateElement = (element: Element): Element => { if (element.id !== action.payload.id) return element; // Merge updates while preserving all existing properties const updated = { ...element, // First spread all existing properties (including dynamic ones) ...action.payload.updates, // Then apply updates (only specified properties) updatedAt: new Date(), // Always update timestamp } as unknown as Element; return updated; }; const updatedElements = state.elements.map(updateElement); return { ...state, elements: updatedElements, template: { ...state.template, isModified: true, }, history: updateHistory(state, { ...state, elements: updatedElements, }), }; } case "REMOVE_ELEMENT": { return { ...state, elements: state.elements.filter((el) => el.id !== action.payload), selection: { ...state.selection, selectedElements: state.selection.selectedElements.filter( (id) => id !== action.payload, ), }, template: { ...state.template, isModified: true, }, history: updateHistory(state, { ...state, elements: state.elements.filter((el) => el.id !== action.payload), selection: { ...state.selection, selectedElements: state.selection.selectedElements.filter( (id) => id !== action.payload, ), }, }), }; } case "SET_ELEMENTS": { return { ...state, elements: action.payload, history: updateHistory(state, { ...state, elements: action.payload }), }; } case "SET_SELECTION": { return { ...state, selection: { ...state.selection, selectedElements: action.payload, selectionBounds: calculateSelectionBounds( action.payload, state.elements, ), }, }; } case "CLEAR_SELECTION": { return { ...state, selection: { ...state.selection, selectedElements: [], selectionBounds: undefined, }, }; } case "SET_CANVAS": { return { ...state, canvas: { ...state.canvas, ...action.payload }, }; } case "SET_MODE": { return { ...state, mode: action.payload, }; } case "SET_DRAG_STATE": { return { ...state, drag: { ...state.drag, ...action.payload }, }; } case "SET_SHOW_PREVIEW_MODAL": { return { ...state, showPreviewModal: action.payload, }; } case "SET_HTML_PREVIEW_CONTENT": { return { ...state, htmlPreviewContent: action.payload, }; } case "SET_ORDER_ID": { return { ...state, orderId: action.payload, }; } case "UNDO": { if (!state.history.canUndo) return state; const previousState = state.history.past[state.history.past.length - 1]; return { ...previousState, history: { past: state.history.past.slice(0, -1), present: previousState, future: [state, ...state.history.future], canUndo: state.history.past.length > 1, canRedo: true, }, }; } case "REDO": { if (!state.history.canRedo) return state; const nextState = state.history.future[0]; return { ...nextState, history: { past: [...state.history.past, state], present: nextState, future: state.history.future.slice(1), canUndo: true, canRedo: state.history.future.length > 1, }, }; } case "RESET": { return initialState; } case "SAVE_TEMPLATE": { return { ...state, template: { ...state.template, isNew: false, isModified: false, isSaving: false, lastSaved: new Date(), id: action.payload?.id || state.template.id, name: action.payload?.name || state.template.name, }, }; } case "SET_TEMPLATE_MODIFIED": { return { ...state, template: { ...state.template, isModified: action.payload, }, }; } case "SET_TEMPLATE_SAVING": return { ...state, template: { ...state.template, isSaving: action.payload, }, }; case "UPDATE_TEMPLATE_SETTINGS": return { ...state, template: { ...state.template, ...action.payload, isModified: true, }, }; case "SET_TEMPLATE_LOADING": return { ...state, template: { ...state.template, isLoading: action.payload, }, }; case "TOGGLE_GUIDES": return { ...state, template: { ...state.template, showGuides: !state.template.showGuides, // Note: isModified is NOT set to true for guides toggle }, }; case "LOAD_TEMPLATE": { const rawElements = ((action.payload as Record).elements as Element[]) || []; const repairedElements = repairProductTableProperties(rawElements); // Ne pas convertir, garder les PX directement const clampedElements = clampElementPositions(repairedElements); // Garder les dimensions du canvas si présentes const canvasData = (action.payload as Record).canvas ? { ...state.canvas, ...((action.payload as Record) .canvas as Partial), } : state.canvas; const newState = { ...state, elements: clampedElements, canvas: canvasData, template: { ...state.template, // ✅ Preserve existing template properties id: (action.payload as Record).id as string, name: (action.payload as Record).name as string, description: ((action.payload as Record) .description as string) || "", showGuides: ((action.payload as Record) .showGuides as boolean) ?? true, snapToGrid: ((action.payload as Record) .snapToGrid as boolean) ?? false, marginTop: ((action.payload as Record).marginTop as number) ?? 0, marginBottom: ((action.payload as Record) .marginBottom as number) ?? 0, marginLeft: ((action.payload as Record) .marginLeft as number) ?? 0, marginRight: ((action.payload as Record) .marginRight as number) ?? 0, isNew: false, isModified: false, // ✅ Template chargé de la DB n'est PAS modifié isSaving: false, isLoading: false, // ✅ Template is loaded lastSaved: (action.payload as Record) .lastSaved as Date, }, history: updateHistory(state, { ...state, elements: clampedElements, canvas: canvasData, }), }; return newState; } case "SET_PREVIEW_MODE": return { ...state, previewMode: action.payload, }; case "NEW_TEMPLATE": { return { ...initialState, template: { ...initialState.template, isNew: true, isModified: false, isSaving: false, isLoading: false, // ✅ New template ready immediately }, }; } default: { return state; } } } // Fonctions utilitaires function updateHistory( currentState: BuilderState, newState: BuilderState, ): HistoryState { // ✅ BUGFIX-011: Deep copy the state before storing in history to ensure immutability const stateCopy: BuilderState = { ...currentState, elements: currentState.elements.map((el) => ({ ...el })), // Shallow copy of array + elements canvas: { ...currentState.canvas }, selection: { ...currentState.selection, selectedElements: [...currentState.selection.selectedElements], }, drag: { ...currentState.drag }, template: { ...currentState.template }, history: currentState.history, // Don't deep copy history recursively }; return { past: [...currentState.history.past, stateCopy], present: newState, future: [], canUndo: true, canRedo: false, }; } function calculateSelectionBounds(selectedIds: string[], elements: Element[]) { if (selectedIds.length === 0) return undefined; const selectedElements = elements.filter((el) => selectedIds.includes(el.id)); if (selectedElements.length === 0) return undefined; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; selectedElements.forEach((el) => { minX = Math.min(minX, el.x); minY = Math.min(minY, el.y); maxX = Math.max(maxX, el.x + el.width); maxY = Math.max(maxY, el.y + el.height); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; } // Contexte interface BuilderContextType { state: BuilderState; dispatch: Dispatch; // Actions helpers addElement: (element: Element) => void; updateElement: (id: string, updates: Partial) => void; removeElement: (id: string) => void; setSelection: (ids: string[]) => void; clearSelection: () => void; setCanvas: (canvas: Partial) => void; setMode: (mode: BuilderMode) => void; undo: () => void; redo: () => void; reset: () => void; toggleGrid: () => void; toggleGuides: () => void; zoomIn: () => void; zoomOut: () => void; setZoom: (zoom: number) => void; resetZoom: () => void; } const BuilderContext = createContext(undefined); // Provider interface BuilderProviderProps { children: ReactNode; initialState?: Partial; } export function BuilderProvider({ children, initialState: initialStateProp, }: BuilderProviderProps) { // fusionne l'état par défaut avec l'éventuel initialState passé en prop const mergedInitialState: BuilderState = { ...initialState, ...(initialStateProp || {}), }; const [state, dispatch] = useReducer(builderReducer, mergedInitialState); const canvasSettings = useCanvasSettings(); // ✅ CORRECTION: Flags pour éviter les boucles infinies const zoomInitializedRef = useRef(false); const gridInitializedRef = useRef(false); // Appliquer les paramètres de zoom depuis Canvas Settings au démarrage useEffect(() => { // Appliquer le zoom par défaut depuis les paramètres UNIQUEMENT au démarrage // Le zoom initial du state est 100, donc appliquer seulement si différent et prêt const initialZoom = 100; // Valeur initiale du state if ( canvasSettings.isReady && canvasSettings.zoomDefault !== initialZoom && !zoomInitializedRef.current ) { zoomInitializedRef.current = true; dispatch({ type: "SET_CANVAS", payload: { zoom: Math.max( canvasSettings.zoomMin, Math.min(canvasSettings.zoomDefault, canvasSettings.zoomMax), ), }, }); } }, [ canvasSettings.zoomDefault, canvasSettings.zoomMax, canvasSettings.zoomMin, canvasSettings.isReady, ]); // Synchroniser les paramètres de grille depuis CanvasSettingsContext (uniquement à l'initialisation) useEffect(() => { if (!canvasSettings.isReady || gridInitializedRef.current) return; const canvasUpdates: Partial = {}; const templateUpdates: Partial = {}; // Synchroniser gridSize toujours if (canvasSettings.gridSize !== state.canvas.gridSize) { canvasUpdates.gridSize = canvasSettings.gridSize; } // Synchroniser showGrid : activer si disponible, sinon forcer à false if ( canvasSettings.gridShow && canvasSettings.gridShow !== state.canvas.showGrid ) { canvasUpdates.showGrid = canvasSettings.gridShow; } else if (!canvasSettings.gridShow && state.canvas.showGrid !== false) { canvasUpdates.showGrid = false; } // Synchroniser snapToGrid : activer si disponible, sinon forcer à false if ( canvasSettings.gridShow && canvasSettings.gridSnapEnabled && canvasSettings.gridSnapEnabled !== state.canvas.snapToGrid ) { canvasUpdates.snapToGrid = canvasSettings.gridSnapEnabled; } else if ( (!canvasSettings.gridShow || !canvasSettings.gridSnapEnabled) && state.canvas.snapToGrid !== false ) { canvasUpdates.snapToGrid = false; } // Synchroniser showGuides : activer si disponible, sinon forcer à false if ( canvasSettings.guidesEnabled && canvasSettings.guidesEnabled !== state.template.showGuides ) { templateUpdates.showGuides = canvasSettings.guidesEnabled; } else if ( !canvasSettings.guidesEnabled && state.template.showGuides !== false ) { templateUpdates.showGuides = false; } if (Object.keys(canvasUpdates).length > 0) { dispatch({ type: "SET_CANVAS", payload: canvasUpdates }); } if (Object.keys(templateUpdates).length > 0) { dispatch({ type: "UPDATE_TEMPLATE_SETTINGS", payload: templateUpdates }); } if ( Object.keys(canvasUpdates).length > 0 || Object.keys(templateUpdates).length > 0 ) { gridInitializedRef.current = true; } }, [ canvasSettings.gridSize, canvasSettings.gridShow, canvasSettings.gridSnapEnabled, canvasSettings.guidesEnabled, canvasSettings.isReady, state.canvas.gridSize, state.canvas.showGrid, state.canvas.snapToGrid, state.template.showGuides, ]); // ✅ DISABLED: Template loading is now EXCLUSIVELY handled by useTemplate hook // which reads template_id from URL/localized data and calls AJAX GET // This prevents duplicate/race condition loads which caused double canvas renders // Previously: BuilderContext useEffect loaded → dispatch → Canvas renders // useTemplate hook also loaded → dispatch → Canvas renders AGAIN (1/10th sec later) // Now: Only useTemplate.ts loads the template, ensuring single source of truth // Écouteur pour le chargement de template via API globale useEffect(() => { const handleLoadTemplate = (event: CustomEvent) => { const templateData = event.detail; if (templateData) { dispatch({ type: "LOAD_TEMPLATE", payload: templateData, }); } else { debugWarn("[BuilderContext] No template data in event detail"); } }; document.addEventListener( "pdfBuilderLoadTemplate", handleLoadTemplate as EventListener, { passive: true }, ); return () => { document.removeEventListener( "pdfBuilderLoadTemplate", handleLoadTemplate as EventListener, ); }; }, []); // Actions helpers const addElement = (element: Element) => { dispatch({ type: "ADD_ELEMENT", payload: element }); }; const updateElement = (id: string, updates: Partial) => { dispatch({ type: "UPDATE_ELEMENT", payload: { id, updates } }); }; const removeElement = (id: string) => { dispatch({ type: "REMOVE_ELEMENT", payload: id }); }; const setSelection = (ids: string[]) => { dispatch({ type: "SET_SELECTION", payload: ids }); }; const clearSelection = () => { dispatch({ type: "CLEAR_SELECTION" }); }; const setCanvas = (canvas: Partial) => { dispatch({ type: "SET_CANVAS", payload: canvas }); }; const setMode = (mode: BuilderMode) => { dispatch({ type: "SET_MODE", payload: mode }); }; const undo = () => { dispatch({ type: "UNDO" }); }; const redo = () => { dispatch({ type: "REDO" }); }; const reset = () => { dispatch({ type: "RESET" }); }; const toggleGrid = () => { setCanvas({ showGrid: !state.canvas.showGrid }); }; const toggleGuides = () => { dispatch({ type: "TOGGLE_GUIDES" }); }; const value: BuilderContextType = { state, dispatch, addElement, updateElement, removeElement, setSelection, clearSelection, setCanvas, setMode, undo, redo, reset, toggleGrid: toggleGrid, toggleGuides: toggleGuides, zoomIn: () => setCanvas({ zoom: Math.min( state.canvas.zoom + canvasSettings.zoomStep, canvasSettings.zoomMax, ), }), zoomOut: () => setCanvas({ zoom: Math.max( state.canvas.zoom - canvasSettings.zoomStep, canvasSettings.zoomMin, ), }), setZoom: (zoom: number) => setCanvas({ zoom: Math.max( canvasSettings.zoomMin, Math.min(zoom, canvasSettings.zoomMax), ), }), resetZoom: () => setCanvas({ zoom: Math.max( canvasSettings.zoomMin, Math.min(canvasSettings.zoomDefault, canvasSettings.zoomMax), ), }), }; return ( {children} ); } // Hook pour utiliser le contexte export function useBuilder(): BuilderContextType { const context = useContext(BuilderContext); if (context === undefined) { throw new Error("useBuilder must be used within a BuilderProvider"); } return context; } // Hook spécialisé pour les éléments export function useElements() { const { state, addElement, updateElement, removeElement } = useBuilder(); return { elements: state.elements, addElement, updateElement, removeElement, getElement: (id: string) => state.elements.find((el) => el.id === id), getElementsByType: (type: Element["type"]) => state.elements.filter((el) => el.type === type), }; } // Hook spécialisé pour la sélection export function useSelection() { const { state, setSelection, clearSelection } = useBuilder(); return { selectedElements: state.selection.selectedElements, selectionBounds: state.selection.selectionBounds, isSelecting: state.selection.isSelecting, setSelection, clearSelection, isSelected: (id: string) => state.selection.selectedElements.includes(id), toggleSelection: (id: string) => { const isSelected = state.selection.selectedElements.includes(id); if (isSelected) { setSelection( state.selection.selectedElements.filter( (selectedId) => selectedId !== id, ), ); } else { setSelection([...state.selection.selectedElements, id]); } }, }; } // Hook spécialisé pour le canvas export function useCanvas() { const { state, setCanvas } = useBuilder(); const canvasSettings = useCanvasSettings(); return { canvas: state.canvas, setCanvas, zoomIn: () => setCanvas({ zoom: Math.min( state.canvas.zoom + canvasSettings.zoomStep, canvasSettings.zoomMax, ), }), zoomOut: () => setCanvas({ zoom: Math.max( state.canvas.zoom - canvasSettings.zoomStep, canvasSettings.zoomMin, ), }), setZoom: (zoom: number) => setCanvas({ zoom: Math.max( canvasSettings.zoomMin, Math.min(zoom, canvasSettings.zoomMax), ), }), resetZoom: () => setCanvas({ zoom: Math.max( canvasSettings.zoomMin, Math.min(canvasSettings.zoomDefault, canvasSettings.zoomMax), ), }), toggleGrid: () => setCanvas({ showGrid: !state.canvas.showGrid }), setBackgroundColor: (color: string) => setCanvas({ backgroundColor: color }), }; } export { BuilderContext }; // Pas d'export de conversion MM/PX - on utilise que PX