PROFESSIONAL LAYOUT EDITOR
Version 1.0.0 | Octobre 2025
Simon Dupont-Gellert & Clémence brunet
`; root.file('viewer.html', viewerHtml); // 5) Add README.txt with Windows unblock instructions const readme = `═══════════════════════════════════════════════════════════════════ MINI SITE GÉNÉRÉ PAR SUPERPRINT ═══════════════════════════════════════════════════════════════════ Contenu du package : • index.html → Galerie (grille de vignettes) • viewer.html → Viewer page par page avec navigation • assets/pages/ → Images de vos pages (PNG) • assets/css/ → Feuilles de style • assets/fonts/ → Polices personnalisées (si chargées) 🎯 DEUX MODES D'AFFICHAGE : 1) index.html → Galerie complète (clic pour agrandir) Parfait pour : vue d'ensemble, sélection rapide 2) viewer.html → Navigation page par page Parfait pour : lecture linéaire, présentation Navigation : boutons ◀▶ ou flèches clavier ←→ ═══════════════════════════════════════════════════════════════════ ⚠️ ALERTE DE SÉCURITÉ WINDOWS ? ═══════════════════════════════════════════════════════════════════ Si Windows affiche une alerte lors de l'ouverture d'un fichier HTML, c'est une protection normale pour les fichiers téléchargés. Ce package est SÉCURISÉ (généré par votre propre application). 🔓 SOLUTION 1 : Débloquer le fichier ZIP AVANT de dézipper 1. Faites un clic droit sur le fichier .zip téléchargé 2. Sélectionnez "Propriétés" 3. En bas de l'onglet "Général", cochez "Débloquer" 4. Cliquez "OK" 5. Puis dézippez le fichier → Tous les fichiers seront automatiquement débloqués ! 🔓 SOLUTION 2 : Débloquer les fichiers HTML après dézippage 1. Faites un clic droit sur "index.html" (ou "viewer.html") 2. Sélectionnez "Propriétés" 3. En bas de l'onglet "Général", cochez "Débloquer" 4. Cliquez "OK" 5. Ouvrez à nouveau le fichier 🌐 SOLUTION 3 : Utiliser un serveur web local (pas d'alerte !) Au lieu de double-cliquer, lancez un serveur dans ce dossier : Avec Python (si installé) : cd chemin\\vers\\ce\\dossier python -m http.server 8000 → Ouvrez http://localhost:8000 Avec Node.js (si installé) : cd chemin\\vers\\ce\\dossier npx serve → Suivez les instructions affichées Avec PHP (si installé) : cd chemin\\vers\\ce\\dossier php -S localhost:8000 → Ouvrez http://localhost:8000 ═══════════════════════════════════════════════════════════════════ 📤 PUBLICATION EN LIGNE ═══════════════════════════════════════════════════════════════════ Pour publier ce mini site sur Internet : • GitHub Pages (gratuit) → github.com • Netlify Drop (gratuit, glisser-déposer) → netlify.com/drop • Vercel (gratuit) → vercel.com • Votre hébergeur web (FTP) Uploadez simplement tout le contenu de ce dossier. Les deux fichiers (index.html et viewer.html) fonctionneront en ligne. ═══════════════════════════════════════════════════════════════════ 💡 CARACTÉRISTIQUES TECHNIQUES ═══════════════════════════════════════════════════════════════════ • Fonctionne hors ligne (une fois débloqué) • Pas de base de données requise • Compatible mobile et tablette • Images optimisées avec lazy loading • Polices personnalisées embarquées • JavaScript minimal (viewer uniquement : 15 lignes) • 100% HTML/CSS pour la galerie (index.html) • Pas de dépendances externes (sauf Google Fonts) • Imprimable (viewer.html cache la toolbar) ═══════════════════════════════════════════════════════════════════ Généré le ${new Date().toLocaleString()} SuperPrint — Logiciel de mise en page professionnel https://superprint.app `; root.file('README.txt', readme); // 6) Save ZIP const zipBlob = await zip.generateAsync({ type: 'blob' }); const a = document.createElement('a'); const url = URL.createObjectURL(zipBlob); a.href = url; a.download = `${siteName}.zip`; a.click(); URL.revokeObjectURL(url); } // ======================================== // 🎨 CONVERSION CMYK LOCALE (pdf-lib) // ======================================== /** * Convertit une couleur RGB en CMYK avec des formules optimisées pour l'impression offset * @param {number} r - Rouge (0-255) * @param {number} g - Vert (0-255) * @param {number} b - Bleu (0-255) * @param {string} profile - Profil ICC (FOGRA39, FOGRA51, SWOP, JapanColor) * @returns {object} - {c, m, y, k} en pourcentages (0-100) */ async function confirmExport() { const modal = document.getElementById('exportModal'); modal.style.display = 'none'; const exportQuality = document.querySelector('input[name="exportQuality"]:checked')?.value || 'standard'; const colorModeSelected = document.querySelector('input[name="colorMode"]:checked')?.value || 'rgb'; const forceHyphenation = document.getElementById('forceHyphenExport') ? document.getElementById('forceHyphenExport').checked : false; // 🎯 IMPORTANT : Relire la valeur de bleed depuis l'input pour s'assurer qu'elle est à jour const bleedInputValue = parseFloat(document.getElementById('bleed').value) || 3; const currentBleed = (currentLanguage === 'en') ? inToMm(bleedInputValue) : bleedInputValue; const options = { cropMarks: document.getElementById('cropMarks').checked, colorBars: document.getElementById('colorBars').checked, pagesMode: document.querySelector('input[name="pagesMode"]:checked').value, colorMode: colorModeSelected, quality: exportQuality, forceHyphenation: forceHyphenation, isHD: exportQuality === 'hd', isMedium: exportQuality === 'medium', isStandard: exportQuality === 'standard' }; try { const { jsPDF } = window.jspdf; // Mode double page - utilise la même logique que l'imposition if (options.pagesMode === 'spread') { const spreadWidth = (pageFormat.width * 2) + (currentBleed * 2); const spreadHeight = pageFormat.height + (currentBleed * 2); // Configuration PDF avec qualité HD si sélectionnée const pdfConfig = { orientation: 'landscape', unit: 'mm', format: [spreadWidth, spreadHeight] }; if (options.isHD) { pdfConfig.putOnlyUsedFonts = true; pdfConfig.compress = false; } const pdf = new jsPDF(pdfConfig); // 🎯 EXPORT DOUBLE PAGE TYPE LIVRE (InDesign style) // - Première page (couverture) : vide à gauche, page 1 à droite // - Pages internes : paires 2-3, 4-5, 6-7... // - Dernière page (si nombre pair) : seule à gauche, vide à droite let plancheCount = 0; // PLANCHE 1 : Page vide + Page 1 (couverture à droite) if (pages.length > 0) { if (plancheCount > 0) { pdf.addPage([spreadWidth, spreadHeight], 'landscape'); } plancheCount++; // Fond blanc complet + masques const overscan = 0.5; pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, spreadWidth, spreadHeight, 'F'); pdf.rect(0, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); const maskWidth = 1.5; pdf.rect(0, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed + pageFormat.width - maskWidth, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); // Conversion N&B helper async function convertToGrayscale(imgDataURL) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); return new Promise((resolve) => { img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); data[i] = gray; data[i + 1] = gray; data[i + 2] = gray; } ctx.putImageData(imageData, 0, 0); resolve(canvas.toDataURL('image/png')); }; img.src = imgDataURL; }); } // Page gauche VIDE pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); // Page droite : PAGE 1 (couverture) let imgData1 = await renderPageToImageWithBleed(0, 'right', options.quality, currentBleed, options.forceHyphenation); if (options.colorMode === 'bw') { imgData1 = await convertToGrayscale(imgData1); } const imageFormat = 'PNG'; const compression = options.isHD ? 'NONE' : 'MEDIUM'; const x = pageFormat.width + currentBleed; const y = -overscan; const w = (pageFormat.width + currentBleed) + overscan; const h = (pageFormat.height + (currentBleed * 2)) + (overscan * 2); pdf.addImage(imgData1, imageFormat, x, y, w, h, undefined, compression); // Crop marks pour première planche if (options.cropMarks) { pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.08); const cropLength = 5; const cropOffset = 2; // Page gauche vide - coins extérieurs seulement const leftPageX = currentBleed; const leftPageY = currentBleed; pdf.line(leftPageX - cropOffset - cropLength, leftPageY, leftPageX - cropOffset, leftPageY); pdf.line(leftPageX, leftPageY - cropOffset - cropLength, leftPageX, leftPageY - cropOffset); pdf.line(leftPageX - cropOffset - cropLength, leftPageY + pageFormat.height, leftPageX - cropOffset, leftPageY + pageFormat.height); pdf.line(leftPageX, leftPageY + pageFormat.height + cropOffset, leftPageX, leftPageY + pageFormat.height + cropOffset + cropLength); // Page droite (page 1) - coins extérieurs const rightPageX = pageFormat.width + currentBleed; const rightPageY = currentBleed; pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY); pdf.line(rightPageX + pageFormat.width, rightPageY - cropOffset - cropLength, rightPageX + pageFormat.width, rightPageY - cropOffset); pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY + pageFormat.height, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY + pageFormat.height); pdf.line(rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset, rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset + cropLength); } } // PLANCHES INTERNES : Pages 2-3, 4-5, 6-7... for (let i = 1; i < pages.length - 1; i += 2) { if (plancheCount > 0) { pdf.addPage([spreadWidth, spreadHeight], 'landscape'); } plancheCount++; // Fond blanc complet + masques const overscan = 0.5; pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, spreadWidth, spreadHeight, 'F'); pdf.rect(0, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); const maskWidth = 1.5; pdf.rect(0, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed + pageFormat.width - maskWidth, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); // Helper conversion N&B async function convertToGrayscale(imgDataURL) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); return new Promise((resolve) => { img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; for (let j = 0; j < data.length; j += 4) { const gray = Math.round(0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]); data[j] = gray; data[j + 1] = gray; data[j + 2] = gray; } ctx.putImageData(imageData, 0, 0); resolve(canvas.toDataURL('image/png')); }; img.src = imgDataURL; }); } // Page gauche (i) let imgDataLeft = await renderPageToImageWithBleed(i, 'left', options.quality, currentBleed, options.forceHyphenation); if (options.colorMode === 'bw') { imgDataLeft = await convertToGrayscale(imgDataLeft); } const imageFormat = 'PNG'; const compression = options.isHD ? 'NONE' : 'MEDIUM'; pdf.addImage(imgDataLeft, imageFormat, -overscan, -overscan, (pageFormat.width + currentBleed) + overscan, (pageFormat.height + (currentBleed * 2)) + (overscan * 2), undefined, compression); // Page droite (i+1) let imgDataRight = await renderPageToImageWithBleed(i + 1, 'right', options.quality, currentBleed, options.forceHyphenation); if (options.colorMode === 'bw') { imgDataRight = await convertToGrayscale(imgDataRight); } pdf.addImage(imgDataRight, imageFormat, pageFormat.width + currentBleed, -overscan, (pageFormat.width + currentBleed) + overscan, (pageFormat.height + (currentBleed * 2)) + (overscan * 2), undefined, compression); // Crop marks if (options.cropMarks) { pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.08); const cropLength = 5; const cropOffset = 2; const leftPageX = currentBleed; const leftPageY = currentBleed; const rightPageX = pageFormat.width + currentBleed; const rightPageY = currentBleed; // Page gauche coins extérieurs pdf.line(leftPageX - cropOffset - cropLength, leftPageY, leftPageX - cropOffset, leftPageY); pdf.line(leftPageX, leftPageY - cropOffset - cropLength, leftPageX, leftPageY - cropOffset); pdf.line(leftPageX - cropOffset - cropLength, leftPageY + pageFormat.height, leftPageX - cropOffset, leftPageY + pageFormat.height); pdf.line(leftPageX, leftPageY + pageFormat.height + cropOffset, leftPageX, leftPageY + pageFormat.height + cropOffset + cropLength); // Page droite coins extérieurs pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY); pdf.line(rightPageX + pageFormat.width, rightPageY - cropOffset - cropLength, rightPageX + pageFormat.width, rightPageY - cropOffset); pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY + pageFormat.height, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY + pageFormat.height); pdf.line(rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset, rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset + cropLength); } } // DERNIÈRE PAGE (si nombre pair) : seule à gauche + page vide à droite if (pages.length > 1 && pages.length % 2 === 0) { pdf.addPage([spreadWidth, spreadHeight], 'landscape'); // Fond blanc complet + masques const overscan = 0.5; pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, spreadWidth, spreadHeight, 'F'); pdf.rect(0, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); const maskWidth = 1.5; pdf.rect(0, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed + pageFormat.width - maskWidth, 0, currentBleed + maskWidth, spreadHeight, 'F'); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width, currentBleed + maskWidth, 'F'); pdf.rect(pageFormat.width + currentBleed, spreadHeight - currentBleed - maskWidth, pageFormat.width, currentBleed + maskWidth, 'F'); // Helper conversion N&B async function convertToGrayscale(imgDataURL) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); return new Promise((resolve) => { img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; for (let j = 0; j < data.length; j += 4) { const gray = Math.round(0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]); data[j] = gray; data[j + 1] = gray; data[j + 2] = gray; } ctx.putImageData(imageData, 0, 0); resolve(canvas.toDataURL('image/png')); }; img.src = imgDataURL; }); } // Page gauche : dernière page const lastPageIndex = pages.length - 1; let imgDataLast = await renderPageToImageWithBleed(lastPageIndex, 'left', options.quality, currentBleed, options.forceHyphenation); if (options.colorMode === 'bw') { imgDataLast = await convertToGrayscale(imgDataLast); } const imageFormat = 'PNG'; const compression = options.isHD ? 'NONE' : 'MEDIUM'; pdf.addImage(imgDataLast, imageFormat, -overscan, -overscan, (pageFormat.width + currentBleed) + overscan, (pageFormat.height + (currentBleed * 2)) + (overscan * 2), undefined, compression); // Page droite VIDE pdf.setFillColor(255, 255, 255); pdf.rect(pageFormat.width + currentBleed, 0, pageFormat.width + currentBleed, pageFormat.height + (currentBleed * 2), 'F'); // Crop marks if (options.cropMarks) { pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.08); const cropLength = 5; const cropOffset = 2; const leftPageX = currentBleed; const leftPageY = currentBleed; const rightPageX = pageFormat.width + currentBleed; const rightPageY = currentBleed; // Page gauche (dernière page) coins extérieurs pdf.line(leftPageX - cropOffset - cropLength, leftPageY, leftPageX - cropOffset, leftPageY); pdf.line(leftPageX, leftPageY - cropOffset - cropLength, leftPageX, leftPageY - cropOffset); pdf.line(leftPageX - cropOffset - cropLength, leftPageY + pageFormat.height, leftPageX - cropOffset, leftPageY + pageFormat.height); pdf.line(leftPageX, leftPageY + pageFormat.height + cropOffset, leftPageX, leftPageY + pageFormat.height + cropOffset + cropLength); // Page droite vide coins extérieurs pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY); pdf.line(rightPageX + pageFormat.width, rightPageY - cropOffset - cropLength, rightPageX + pageFormat.width, rightPageY - cropOffset); pdf.line(rightPageX + pageFormat.width + cropOffset, rightPageY + pageFormat.height, rightPageX + pageFormat.width + cropOffset + cropLength, rightPageY + pageFormat.height); pdf.line(rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset, rightPageX + pageFormat.width, rightPageY + pageFormat.height + cropOffset + cropLength); } } const qualityLabel = options.isHD ? 'HD-300DPI' : 'standard'; const now = new Date(); const dateStr = now.toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); const filename = `superprint-spread-${qualityLabel}-${dateStr}.pdf`; pdf.save(filename); // 🔧 Utilisation des fonctions globales (optimisé) const qualityInfo = getQualityInfo(options.quality); const colorModeText = options.colorMode === 'bw' ? 'N&B' : 'RGB'; let message = `PDF double page exporté avec succès!\n${Math.ceil(pages.length / 2)} planches créées\nFormat: ${spreadWidth}×${spreadHeight}mm (avec fonds perdu)\nMode: ${colorModeText}`; message += `\nQualité: ${qualityInfo}`; if (options.cropMarks) { message += '\nTraits de coupe: Oui'; } alert(message); return; } // Mode pages simples const extraSpace = options.cropMarks ? 10 : 0; // 🎯 IMPORTANT : Utiliser le bleed des canvas ACTUELLEMENT AFFICHÉS // Les objets ont été créés avec le bleed qui est actuellement dans les canvas // On doit lire ce bleed depuis les canvas, pas depuis l'input const firstCanvas = canvases[0]; const actualBleedPx = firstCanvas && firstCanvas.bleedInfo ? firstCanvas.bleedInfo.left : mmToPx(currentBleed); const actualBleed = pxToMm(actualBleedPx); // Espace supplémentaire pour la barre chromatique si activée const extraBarSpace = options.colorBars ? 10 : 0; // 3mm barre + 3mm espace + marge // Format canvas éditeur = pageFormat + (bleed actuel × 2) const canvasWidth = pageFormat.width + (actualBleed * 2); const canvasHeight = pageFormat.height + (actualBleed * 2); const pdfWidth = canvasWidth + (extraSpace * 2); const pdfHeight = canvasHeight + (extraSpace * 2) + extraBarSpace; // Configuration PDF avec qualité HD si sélectionnée const pdfConfig = { orientation: pageFormat.width > pageFormat.height ? 'landscape' : 'portrait', unit: 'mm', format: [pdfWidth, pdfHeight] }; if (options.isHD) { pdfConfig.putOnlyUsedFonts = true; pdfConfig.compress = false; } const pdf = new jsPDF(pdfConfig); // Empêcher toute sélection/curseur d'être rendue dans l'export if (typeof activeCanvas !== 'undefined' && activeCanvas) { try { if (activeCanvas.getActiveObject()?.isEditing) { activeCanvas.getActiveObject().exitEditing(); } activeCanvas.discardActiveObject(); activeCanvas.renderAll(); } catch (e) {} } // Surcote (0,5 mm) pour éliminer tout filet aux bords const overscan = 0.5; // en mm for (let i = 0; i < pages.length; i++) { if (i > 0) { pdf.addPage([pdfWidth, pdfHeight]); } // 🎯 Canvas temporaire de la MÊME taille que dans l'éditeur (incluant bleed) // Si colorBars activé, ajouter de l'espace en bas pour la barre chromatique const extraBarSpace = options.colorBars ? 10 : 0; // 3mm barre + 3mm espace + marge const tempCanvas = document.createElement('canvas'); const tempFabric = new fabric.Canvas(tempCanvas, { width: mmToPx(canvasWidth), height: mmToPx(canvasHeight + extraBarSpace), backgroundColor: '#ffffff' }); // 🎯 FIX CRITIQUE: Dessiner un rectangle blanc en fond AVANT de charger les objets const whiteRect = new fabric.Rect({ left: 0, top: 0, width: mmToPx(canvasWidth), height: mmToPx(canvasHeight + extraBarSpace), fill: '#ffffff', selectable: false, evented: false, isBackgroundRect: true }); tempFabric.add(whiteRect); tempFabric.sendToBack(whiteRect); await new Promise((resolve) => { if (pages[i].objects) { tempFabric.loadFromJSON(pages[i].objects, async () => { // Supprimer les éléments de maquette const objectsToRemove = []; tempFabric.getObjects().forEach(obj => { if (obj.isMargin || obj.isBleed || obj.isTrimBox || obj.isGuide || obj.isManualGuide) { objectsToRemove.push(obj); } }); objectsToRemove.forEach(obj => tempFabric.remove(obj)); // S'assurer que le fond blanc reste en arrière-plan tempFabric.sendToBack(whiteRect); // Forcer la césure si demandé (pages simples) if (options.forceHyphenation) { tempFabric.getObjects().forEach(obj => { if (obj.type === 'textbox') { obj.enableHyphenation = true; obj.hyphenLanguage = obj.hyphenLanguage || (typeof currentHyphenLanguage !== 'undefined' ? currentHyphenLanguage : 'fr'); if (typeof obj._clearCache === 'function') obj._clearCache(); if (typeof obj.initDimensions === 'function') obj.initDimensions(); if (typeof obj.setCoords === 'function') obj.setCoords(); } }); } // Les objets sont déjà positionnés correctement par rapport au canvas avec bleed // Pas besoin de les décaler, juste appliquer les optimisations tempFabric.getObjects().forEach(obj => { // 🔒 Anti-hairline: supprimer les traits ultra fins et bords translucides if (obj.stroke && (obj.strokeWidth === 0 || obj.strokeWidth < 0.5)) { obj.stroke = 'transparent'; obj.strokeWidth = 0; } // Pour les formes pleines, éviter un demi-pixel transparent en bord if ((obj.type === 'rect' || obj.type === 'circle' || obj.type === 'triangle' || obj.type === 'path') && obj.fill && obj.fill !== 'transparent') { // Nudge minime à l’export pour coller au pixel obj.left = Math.round(obj.left) + 0.01; obj.top = Math.round(obj.top) + 0.01; } obj.setCoords(); }); // Traits de coupe déplacés en vectoriel (jsPDF) pour finesse constante if (options.colorBars) { // Barre chromatique fine et centrée sous le format de page const barHeight = mmToPx(3); // Hauteur 3mm // Position Y : après le canvas principal (qui inclut bleed) + 3mm d'espace const barY = mmToPx(canvasHeight) + mmToPx(3); const barWidth = mmToPx(pageFormat.width) / 7; // Position X : alignée avec le début du format de page (après le bleed) const barStartX = mmToPx(actualBleed); ['#00FFFF', '#FF00FF', '#FFFF00', '#000000', '#00FF00', '#FF0000', '#0000FF'].forEach((color, idx) => { tempFabric.add(new fabric.Rect({ left: barStartX + (idx * barWidth), top: barY, width: barWidth, height: barHeight, fill: color, selectable: false })); }); } tempFabric.renderAll(); // Configuration qualité d'image basée sur l'option sélectionnée function getMultiplier(quality) { switch(quality) { case 'standard': return 1; // 72 DPI (base Fabric.js) case 'medium': return 2.08; // 150 DPI (150/72 ≈ 2.08) case 'hd': return 4.17; // 300 DPI (300/72 ≈ 4.17) default: return 1; } } function convertToGrayscale(imgDataURL) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); return new Promise((resolve) => { img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; // Conversion en niveaux de gris for (let i = 0; i < data.length; i += 4) { const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); data[i] = gray; // Rouge data[i + 1] = gray; // Vert data[i + 2] = gray; // Bleu // Alpha reste inchangé } ctx.putImageData(imageData, 0, 0); resolve(canvas.toDataURL('image/png')); }; img.src = imgDataURL; }); } // 🚀 Rendu image: forcer PNG (évite halos/compressions et alphas indésirables) const imageConfig = { format: 'png', quality: 1, multiplier: getQualityMultiplier(options.quality) // 🔧 Fonction globale }; let imgData = tempFabric.toDataURL(imageConfig); // Conversion noir & blanc si nécessaire if (options.colorMode === 'bw') { imgData = await convertToGrayscale(imgData); } // 🚀 Choix format: forcer PNG pour éviter halos de JPEG au bord const imageFormat = 'PNG'; const compression = 'FAST'; // Fond blanc exactement au format PDF pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, pdfWidth, pdfHeight, 'F'); // 🎯 Position de l'image dans le PDF // Si extraSpace > 0 (crop marks), l'image doit être décalée pour laisser de la place // L'image elle-même contient déjà les fonds perdus du canvas const imgOffsetX = extraSpace; const imgOffsetY = extraSpace; // Image avec surcote de 0,5 mm OUTWARD sur tous les bords pour éliminer tout filet const imgX = imgOffsetX - overscan; const imgY = imgOffsetY - overscan; const imgW = canvasWidth + (overscan * 2); const imgH = (canvasHeight + extraBarSpace) + (overscan * 2); pdf.addImage( imgData, imageFormat, imgX, imgY, imgW, imgH, undefined, options.isHD ? 'NONE' : 'FAST' ); // Dessiner les traits de coupe en vectoriel (fins) if (options.cropMarks) { const cropLen = 5; // mm const cropOff = 2.5; // mm // Position du format final (page) dans le PDF = fond perdu + espace crop marks const x0 = actualBleed + extraSpace; const y0 = actualBleed + extraSpace; const x1 = actualBleed + extraSpace + pageFormat.width; const y1 = actualBleed + extraSpace + pageFormat.height; pdf.setDrawColor(0, 0, 0); pdf.setLineWidth(0.08); // Haut gauche pdf.line(x0 - cropOff - cropLen, y0, x0 - cropOff, y0); pdf.line(x0, y0 - cropOff - cropLen, x0, y0 - cropOff); // Haut droit pdf.line(x1 + cropOff, y0, x1 + cropOff + cropLen, y0); pdf.line(x1, y0 - cropOff - cropLen, x1, y0 - cropOff); // Bas gauche pdf.line(x0 - cropOff - cropLen, y1, x0 - cropOff, y1); pdf.line(x0, y1 + cropOff, x0, y1 + cropOff + cropLen); // Bas droit pdf.line(x1 + cropOff, y1, x1 + cropOff + cropLen, y1); pdf.line(x1, y1 + cropOff, x1, y1 + cropOff + cropLen); } tempFabric.dispose(); resolve(); }); } else { resolve(); } }); } // 🔧 Utilisation des fonctions globales (optimisé) const qualityInfo = getQualityInfo(options.quality); const colorModeText = options.colorMode === 'bw' ? 'N&B' : 'RGB'; pdf.setProperties({ title: `SuperPrint - Document ${colorModeText} ${qualityInfo}`, subject: options.isHD ? 'Document haute qualité pour impression' : 'Document pour affichage numérique', author: 'SuperPrint', keywords: `RGB, ${options.isHD ? 'HD, 300DPI, Print' : 'Digital, Screen, 72DPI'}`, creator: 'SuperPrint Professional Layout Editor' }); const qualityLabel = getQualityLabel(options.quality); const colorLabel = options.colorMode === 'bw' ? 'nb' : 'rgb'; const now = new Date(); const dateStr = now.toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); const filename = `superprint-${colorLabel}-${qualityLabel}-${dateStr}.pdf`; pdf.save(filename); let message = `PDF exporté avec succès!\nMode: ${colorModeText}\nQualité: ${qualityInfo}\nTraits de coupe: ${options.cropMarks ? 'Oui' : 'Non'}\nRepères colorimétriques: ${options.colorBars ? 'Oui' : 'Non'}`; alert(message); } catch (error) { console.error('Erreur lors de l\'export PDF:', error); alert('Erreur lors de l\'export du PDF. Consultez la console pour plus de détails.'); } } function updateFontWeightOptions(fontFamily) { const weightSelect = document.getElementById('fontWeight'); const currentValue = weightSelect.value; const availableWeights = fontWeights[fontFamily] || ['400']; // Vider les options actuelles weightSelect.innerHTML = ''; // Ajouter les graisses disponibles const weightLabels = { '300': 'Light (300)', '400': 'Regular (400)', '500': 'Medium (500)', '600': 'SemiBold (600)', '700': 'Bold (700)', '800': 'ExtraBold (800)', '900': 'Black (900)' }; availableWeights.forEach(weight => { const option = document.createElement('option'); option.value = weight; option.textContent = weightLabels[weight] || `Weight ${weight}`; if (weight === currentValue || (availableWeights.indexOf(currentValue) === -1 && weight === '400')) { option.selected = true; } weightSelect.appendChild(option); }); } function toggleRulers(canvasElement, visible) { // Trouver le conteneur parent .canvas-inner const canvasInner = canvasElement.closest('.canvas-inner'); if (!canvasInner) return; // Supprimer les anciennes règles const oldHRuler = canvasInner.querySelector('.ruler-horizontal'); const oldVRuler = canvasInner.querySelector('.ruler-vertical'); if (oldHRuler) oldHRuler.remove(); if (oldVRuler) oldVRuler.remove(); if (!visible) return; const canvasWidth = canvasElement.width; const canvasHeight = canvasElement.height; const bleedPx = mmToPx(bleed); // Dimensions du format final (sans fond perdu) const finalWidth = pageFormat.width; const finalHeight = pageFormat.height; // Créer règle horizontale (en haut) const hRuler = document.createElement('div'); hRuler.className = 'ruler-horizontal'; hRuler.style.width = canvasWidth + 'px'; // Graduations tous les 10mm, COMMENÇANT au format final (après le fond perdu) for (let mmValue = 0; mmValue <= finalWidth; mmValue += 10) { const x = bleedPx + mmToPx(mmValue); // Décalage du fond perdu if (x > canvasWidth) break; const isMajor = mmValue % 50 === 0; const tickHeight = isMajor ? 8 : (mmValue % 10 === 0 ? 5 : 3); const tick = document.createElement('div'); tick.className = 'ruler-tick' + (isMajor ? ' major' : ''); tick.style.left = x + 'px'; tick.style.bottom = '0'; tick.style.width = (isMajor ? '1px' : '0.5px'); tick.style.height = tickHeight + 'px'; hRuler.appendChild(tick); // Ajouter étiquette tous les 50mm if (isMajor) { const label = document.createElement('div'); label.className = 'ruler-label'; label.textContent = mmValue; label.style.left = (x + 2) + 'px'; label.style.top = '2px'; hRuler.appendChild(label); } } // Drag depuis la règle pour créer un repère horizontal (() => { let dragging = false; let ghostLine = null; let ghostLabel = null; const onMouseMove = (e) => { if (!dragging) return; const innerRect = canvasInner.getBoundingClientRect(); let y = e.clientY - innerRect.top; // clamp y = Math.max(0, Math.min(canvasHeight, y)); const mmValue = Math.round(pxToMm(y - mmToPx(bleed))); ghostLine.style.top = y + 'px'; ghostLabel.style.top = (y - 12) + 'px'; ghostLabel.textContent = mmValue + 'mm'; }; const finish = (e) => { if (!dragging) return; dragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', finish); const innerRect = canvasInner.getBoundingClientRect(); const y = e.clientY - innerRect.top; // If released outside canvas vertically above the top ruler area, cancel if (e.clientY < innerRect.top) { if (ghostLine) ghostLine.remove(); if (ghostLabel) ghostLabel.remove(); ghostLine = ghostLabel = null; return; } // finalize as guide if (ghostLine && ghostLabel) { makeGuideDraggable(ghostLine, ghostLabel, 'horizontal'); } ghostLine = ghostLabel = null; }; hRuler.addEventListener('mousedown', (e) => { dragging = true; const innerRect = canvasInner.getBoundingClientRect(); const startY = e.clientY - innerRect.top; const mmValue = Math.round(pxToMm(startY - mmToPx(bleed))); ghostLine = document.createElement('div'); ghostLine.className = 'guide-line horizontal'; ghostLine.style.top = startY + 'px'; ghostLine.dataset.position = mmValue; ghostLabel = document.createElement('div'); ghostLabel.className = 'guide-label'; ghostLabel.textContent = mmValue + 'mm'; ghostLabel.style.left = '5px'; ghostLabel.style.top = (startY - 12) + 'px'; canvasInner.appendChild(ghostLine); canvasInner.appendChild(ghostLabel); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', finish); }); })(); // Créer règle verticale (à gauche) const vRuler = document.createElement('div'); vRuler.className = 'ruler-vertical'; vRuler.style.height = canvasHeight + 'px'; // Graduations tous les 10mm, COMMENÇANT au format final (après le fond perdu) for (let mmValue = 0; mmValue <= finalHeight; mmValue += 10) { const y = bleedPx + mmToPx(mmValue); // Décalage du fond perdu if (y > canvasHeight) break; const isMajor = mmValue % 50 === 0; const tickWidth = isMajor ? 8 : (mmValue % 10 === 0 ? 5 : 3); const tick = document.createElement('div'); tick.className = 'ruler-tick' + (isMajor ? ' major' : ''); tick.style.top = y + 'px'; tick.style.right = '0'; tick.style.height = (isMajor ? '1px' : '0.5px'); tick.style.width = tickWidth + 'px'; vRuler.appendChild(tick); // Ajouter étiquette tous les 50mm if (isMajor) { const label = document.createElement('div'); label.className = 'ruler-label'; label.textContent = mmValue; label.style.left = '2px'; label.style.top = (y + 2) + 'px'; vRuler.appendChild(label); } } // Drag depuis la règle pour créer un repère vertical (() => { let dragging = false; let ghostLine = null; let ghostLabel = null; const onMouseMove = (e) => { if (!dragging) return; const innerRect = canvasInner.getBoundingClientRect(); let x = e.clientX - innerRect.left; x = Math.max(0, Math.min(canvasWidth, x)); const mmValue = Math.round(pxToMm(x - mmToPx(bleed))); ghostLine.style.left = x + 'px'; ghostLabel.style.left = (x + 5) + 'px'; ghostLabel.textContent = mmValue + 'mm'; }; const finish = (e) => { if (!dragging) return; dragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', finish); const innerRect = canvasInner.getBoundingClientRect(); const x = e.clientX - innerRect.left; if (e.clientX < innerRect.left) { if (ghostLine) ghostLine.remove(); if (ghostLabel) ghostLabel.remove(); ghostLine = ghostLabel = null; return; } if (ghostLine && ghostLabel) { makeGuideDraggable(ghostLine, ghostLabel, 'vertical'); } ghostLine = ghostLabel = null; }; vRuler.addEventListener('mousedown', (e) => { dragging = true; const innerRect = canvasInner.getBoundingClientRect(); const startX = e.clientX - innerRect.left; const mmValue = Math.round(pxToMm(startX - mmToPx(bleed))); ghostLine = document.createElement('div'); ghostLine.className = 'guide-line vertical'; ghostLine.style.left = startX + 'px'; ghostLine.dataset.position = mmValue; ghostLabel = document.createElement('div'); ghostLabel.className = 'guide-label'; ghostLabel.textContent = mmValue + 'mm'; ghostLabel.style.left = (startX + 5) + 'px'; ghostLabel.style.top = '5px'; canvasInner.appendChild(ghostLine); canvasInner.appendChild(ghostLabel); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', finish); }); })(); canvasInner.appendChild(hRuler); canvasInner.appendChild(vRuler); // Rendre existants repères (si déjà présents) déplaçables canvasInner.querySelectorAll('.guide-line.horizontal').forEach(line => { const label = findSiblingLabel(line, 'horizontal'); makeGuideDraggable(line, label, 'horizontal'); }); canvasInner.querySelectorAll('.guide-line.vertical').forEach(line => { const label = findSiblingLabel(line, 'vertical'); makeGuideDraggable(line, label, 'vertical'); }); } // Helpers pour repères DOM: trouver label et rendre déplaçable function findSiblingLabel(line, orientation) { // naive: pick nearest label in DOM by comparing axis proximity const container = line.parentElement; const labels = Array.from(container.querySelectorAll('.guide-label')); const lineRect = line.getBoundingClientRect(); let best = null; let bestDist = Infinity; labels.forEach(l => { const r = l.getBoundingClientRect(); const d = orientation === 'horizontal' ? Math.abs(r.top - lineRect.top) : Math.abs(r.left - lineRect.left); if (d < bestDist) { best = l; bestDist = d; } }); return best; } function makeGuideDraggable(line, label, orientation) { if (!line) return; const container = line.parentElement; const canvasWidth = parseFloat(container.querySelector('canvas').width); const canvasHeight = parseFloat(container.querySelector('canvas').height); const bleedPx = mmToPx(bleed); let dragging = false; const onMove = (e) => { if (!dragging) return; const rect = container.getBoundingClientRect(); if (orientation === 'horizontal') { let y = e.clientY - rect.top; if (y < 0 || y > canvasHeight) { // out of bounds: show faint at edge y = Math.max(0, Math.min(canvasHeight, y)); } line.style.top = y + 'px'; const mmValue = Math.round(pxToMm(y - bleedPx)); if (label) { label.style.top = (y - 12) + 'px'; label.textContent = mmValue + 'mm'; } } else { let x = e.clientX - rect.left; if (x < 0 || x > canvasWidth) { x = Math.max(0, Math.min(canvasWidth, x)); } line.style.left = x + 'px'; const mmValue = Math.round(pxToMm(x - bleedPx)); if (label) { label.style.left = (x + 5) + 'px'; label.textContent = mmValue + 'mm'; } } }; const onUp = (e) => { if (!dragging) return; dragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const rect = container.getBoundingClientRect(); // Delete if released back onto the ruler area if (orientation === 'horizontal') { if (e.clientY < rect.top) { if (label) label.remove(); line.remove(); } } else { if (e.clientX < rect.left) { if (label) label.remove(); line.remove(); } } }; line.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); dragging = true; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); line.addEventListener('dblclick', () => { if (label) label.remove(); line.remove(); }); } function toggleGuides(canvas, visible) { if (!canvas) return; // Supprimer les anciens repères const objectsToRemove = []; canvas.getObjects().forEach(obj => { if (obj.isGuide) { objectsToRemove.push(obj); } }); objectsToRemove.forEach(obj => canvas.remove(obj)); if (!visible) { canvas.renderAll(); return; } // Ajouter repères au centre et aux tiers const guideColor = '#00ccff'; const isSpread = canvas.bleedInfo && canvas.bleedInfo.isSpread; // 🎯 FIX MODE SPREAD: Guides verticaux adaptés if (isSpread) { // DOUBLE-PAGE: 2 guides verticaux (un au centre de chaque page) const leftCenterX = canvas.width / 4; // Centre page gauche const rightCenterX = 3 * canvas.width / 4; // Centre page droite [leftCenterX, rightCenterX].forEach(x => { const line = new fabric.Line([x, 0, x, canvas.height], { stroke: guideColor, strokeWidth: 0.5, strokeDashArray: [5, 5], selectable: false, evented: false, isGuide: true }); canvas.add(line); }); // Guides aux tiers pour page GAUCHE [canvas.width / 6, canvas.width / 3].forEach(x => { const line = new fabric.Line([x, 0, x, canvas.height], { stroke: guideColor, strokeWidth: 0.3, strokeDashArray: [3, 3], selectable: false, evented: false, opacity: 0.5, isGuide: true }); canvas.add(line); }); // Guides aux tiers pour page DROITE [2 * canvas.width / 3, 5 * canvas.width / 6].forEach(x => { const line = new fabric.Line([x, 0, x, canvas.height], { stroke: guideColor, strokeWidth: 0.3, strokeDashArray: [3, 3], selectable: false, evented: false, opacity: 0.5, isGuide: true }); canvas.add(line); }); } else { // PAGE SIMPLE: 1 guide vertical au centre const centerVLine = new fabric.Line([canvas.width / 2, 0, canvas.width / 2, canvas.height], { stroke: guideColor, strokeWidth: 0.5, strokeDashArray: [5, 5], selectable: false, evented: false, isGuide: true }); canvas.add(centerVLine); // Guides aux tiers verticaux (page simple) [canvas.width / 3, (canvas.width * 2) / 3].forEach(x => { const line = new fabric.Line([x, 0, x, canvas.height], { stroke: guideColor, strokeWidth: 0.3, strokeDashArray: [3, 3], selectable: false, evented: false, opacity: 0.5, isGuide: true }); canvas.add(line); }); } // Guide horizontal central (identique en single et spread) const centerHLine = new fabric.Line([0, canvas.height / 2, canvas.width, canvas.height / 2], { stroke: guideColor, strokeWidth: 0.5, strokeDashArray: [5, 5], selectable: false, evented: false, isGuide: true }); canvas.add(centerHLine); // Guides aux tiers horizontaux (identique en single et spread) [canvas.height / 3, (canvas.height * 2) / 3].forEach(y => { const line = new fabric.Line([0, y, canvas.width, y], { stroke: guideColor, strokeWidth: 0.3, strokeDashArray: [3, 3], selectable: false, evented: false, opacity: 0.5, isGuide: true }); canvas.add(line); }); canvas.renderAll(); } function addManualGuide(canvas, orientation, positionMm) { if (!canvas) return; const guideColor = '#ff00ff'; // Magenta pour les repères manuels const positionPx = mmToPx(positionMm); let line; if (orientation === 'vertical') { line = new fabric.Line([positionPx, 0, positionPx, canvas.height], { stroke: guideColor, strokeWidth: 1, strokeDashArray: [5, 5], selectable: false, evented: false, isManualGuide: true, guidePosition: positionMm }); } else { line = new fabric.Line([0, positionPx, canvas.width, positionPx], { stroke: guideColor, strokeWidth: 1, strokeDashArray: [5, 5], selectable: false, evented: false, isManualGuide: true, guidePosition: positionMm }); } canvas.add(line); canvas.renderAll(); } async function exportImposedPDF() { closeImpositionModal(); // Récupérer la qualité sélectionnée const imposedQuality = document.querySelector('input[name="imposedQuality"]:checked')?.value || 'standard'; // 🎯 IMPORTANT : Relire la valeur de bleed depuis l'input pour s'assurer qu'elle est à jour const bleedInputValue = parseFloat(document.getElementById('bleed').value) || 3; const currentBleed = (currentLanguage === 'en') ? inToMm(bleedInputValue) : bleedInputValue; // Afficher le loader const loader = document.createElement('div'); loader.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;color:#fff;font-family:IBM Plex Mono,monospace;'; loader.innerHTML = '
Génération du PDF imposé...
'; document.body.appendChild(loader); try { await new Promise(resolve => setTimeout(resolve, 100)); const { jsPDF } = window.jspdf; const numPages = pages.length; const impositionOrder = calculateImposition(numPages); // Format imposé: 2 pages + fonds perdu extérieurs const sheetWidth = (pageFormat.width * 2) + (currentBleed * 2); const sheetHeight = pageFormat.height + (currentBleed * 2); const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: [sheetWidth, sheetHeight] }); // Grouper par paires - 2 pages par planche for (let i = 0; i < impositionOrder.length; i += 2) { if (i > 0) { pdf.addPage([sheetWidth, sheetHeight], 'landscape'); } const page1 = impositionOrder[i]; const page2 = impositionOrder[i + 1]; await renderImposedSheet(pdf, page1, page2, imposedQuality, currentBleed); } loader.remove(); const now = new Date(); const dateStr = now.toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); const filename = `superprint-imposed-${dateStr}.pdf`; pdf.save(filename); function getQualityInfo(quality) { switch(quality) { case 'standard': return 'Standard 72 DPI'; case 'medium': return 'Medium 150 DPI'; case 'hd': return 'HD 300 DPI'; default: return 'Standard 72 DPI'; } } const qualityText = getQualityInfo(imposedQuality); // Message let alertMessage = `PDF imposé exporté avec succès!\n${Math.ceil(impositionOrder.length / 2)} planches créées\nFormat: ${sheetWidth}×${sheetHeight}mm (avec fonds perdu)`; alertMessage += `\nMode: RGB`; alertMessage += `\nQualité: ${qualityText}`; alertMessage += `\nOrdre: ${impositionOrder.join(', ')}`; alert(alertMessage); } catch (error) { loader.remove(); console.error('Erreur lors de l\'export PDF imposé:', error); alert('Erreur lors de l\'export du PDF imposé. Consultez la console pour plus de détails.'); } } async function renderImposedSheet(pdf, pageNum1, pageNum2, quality = 'standard', bleedValue = null) { const actualBleed = bleedValue !== null ? bleedValue : bleed; const pageWidth = pageFormat.width; const pageHeight = pageFormat.height; const includeCropMarks = document.getElementById('imposedCropMarks').checked; // Full white background to eliminate any seam const sheetWidth = (pageFormat.width * 2) + (actualBleed * 2); const sheetHeight = pageFormat.height + (actualBleed * 2); pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, sheetWidth, sheetHeight, 'F'); // Overscan outward by 0.5 mm to avoid hairlines const overscan = 0.5; // 🎯 MASQUE BLANC DE FOND: Placé AVANT les images pour ne pas masquer le fond perdu // Bande de 1.5 mm sur chaque bord EXTÉRIEUR (imposed PDF - AU FOND) const maskWidth = 1.5; // mm // Page gauche: masques sur gauche/haut/bas (pas à droite = reliure) pdf.rect(0, 0, actualBleed + maskWidth, sheetHeight, 'F'); // gauche pdf.rect(actualBleed, 0, pageWidth, actualBleed + maskWidth, 'F'); // haut pdf.rect(actualBleed, sheetHeight - actualBleed - maskWidth, pageWidth, actualBleed + maskWidth, 'F'); // bas // Page droite: masques sur droite/haut/bas (pas à gauche = reliure) pdf.rect(pageWidth + actualBleed + pageWidth - maskWidth, 0, actualBleed + maskWidth, sheetHeight, 'F'); // droite pdf.rect(pageWidth + actualBleed, 0, pageWidth, actualBleed + maskWidth, 'F'); // haut pdf.rect(pageWidth + actualBleed, sheetHeight - actualBleed - maskWidth, pageWidth, actualBleed + maskWidth, 'F'); // bas // Page gauche avec fond perdu à gauche uniquement if (pageNum1 !== 'Vide') { const pageIndex = pageNum1 - 1; if (pages[pageIndex]) { const imgData = await renderPageToImageWithBleed(pageIndex, 'left', quality, actualBleed); // Position: left page extends outward to the left/top/bottom const x = -overscan; // extend beyond sheet const y = -overscan; const w = (pageWidth + actualBleed) + overscan; // stretch into the gutter naturally const h = (pageHeight + (actualBleed * 2)) + (overscan * 2); pdf.addImage(imgData, 'PNG', x, y, w, h, undefined, quality === 'hd' ? 'NONE' : 'MEDIUM'); } } else { // Page vide - fond blanc avec fond perdu pdf.setFillColor(255, 255, 255); pdf.rect(0, 0, pageWidth + actualBleed, pageHeight + (actualBleed * 2), 'F'); } // Page droite avec fond perdu à droite uniquement if (pageNum2 !== 'Vide') { const pageIndex = pageNum2 - 1; if (pages[pageIndex]) { const imgData = await renderPageToImageWithBleed(pageIndex, 'right', quality, actualBleed); // Position: right page extends outward to the right/top/bottom const x = pageWidth + actualBleed; // gutter aligned const y = -overscan; const w = (pageWidth + actualBleed) + overscan; // extend to the outer edge const h = (pageHeight + (actualBleed * 2)) + (overscan * 2); pdf.addImage(imgData, 'PNG', x, y, w, h, undefined, quality === 'hd' ? 'NONE' : 'MEDIUM'); } } else { // Page vide - fond blanc avec fond perdu pdf.setFillColor(255, 255, 255); pdf.rect(pageWidth + actualBleed, 0, pageWidth + actualBleed, pageHeight + (actualBleed * 2), 'F'); } // Ajouter les traits de coupe si l'option est activée if (includeCropMarks) { pdf.setDrawColor(0, 0, 0); // Thinner crop marks to reduce visibility and avoid viewer "enhance thin lines" issues pdf.setLineWidth(0.08); const cropLength = 5; // Longueur des traits en mm const cropOffset = 2; // Distance du bord en mm // Coins de la page gauche (format final, pas du fond perdu) const leftPageX = actualBleed; const leftPageY = actualBleed; const leftPageWidth = pageWidth; const leftPageHeight = pageHeight; // Traits de coupe page gauche - Coin supérieur gauche pdf.line(leftPageX - cropOffset - cropLength, leftPageY, leftPageX - cropOffset, leftPageY); pdf.line(leftPageX, leftPageY - cropOffset - cropLength, leftPageX, leftPageY - cropOffset); // Coin supérieur droit (pas de trait au milieu - reliure) // Seulement trait horizontal en haut pdf.line(leftPageX + leftPageWidth, leftPageY - cropOffset - cropLength, leftPageX + leftPageWidth, leftPageY - cropOffset); // Coin inférieur gauche pdf.line(leftPageX - cropOffset - cropLength, leftPageY + leftPageHeight, leftPageX - cropOffset, leftPageY + leftPageHeight); pdf.line(leftPageX, leftPageY + leftPageHeight + cropOffset, leftPageX, leftPageY + leftPageHeight + cropOffset + cropLength); // Coin inférieur droit (pas de trait au milieu - reliure) // Seulement trait horizontal en bas pdf.line(leftPageX + leftPageWidth, leftPageY + leftPageHeight + cropOffset, leftPageX + leftPageWidth, leftPageY + leftPageHeight + cropOffset + cropLength); // Coins de la page droite (format final, pas du fond perdu) const rightPageX = pageWidth + bleed; const rightPageY = bleed; const rightPageWidth = pageWidth; const rightPageHeight = pageHeight; // Coin supérieur gauche (pas de trait au milieu - reliure) // Seulement trait horizontal en haut pdf.line(rightPageX, rightPageY - cropOffset - cropLength, rightPageX, rightPageY - cropOffset); // Coin supérieur droit pdf.line(rightPageX + rightPageWidth + cropOffset, rightPageY, rightPageX + rightPageWidth + cropOffset + cropLength, rightPageY); pdf.line(rightPageX + rightPageWidth, rightPageY - cropOffset - cropLength, rightPageX + rightPageWidth, rightPageY - cropOffset); // Coin inférieur gauche (pas de trait au milieu - reliure) // Seulement trait horizontal en bas pdf.line(rightPageX, rightPageY + rightPageHeight + cropOffset, rightPageX, rightPageY + rightPageHeight + cropOffset + cropLength); // Coin inférieur droit pdf.line(rightPageX + rightPageWidth + cropOffset, rightPageY + rightPageHeight, rightPageX + rightPageWidth + cropOffset + cropLength, rightPageY + rightPageHeight); pdf.line(rightPageX + rightPageWidth, rightPageY + rightPageHeight + cropOffset, rightPageX + rightPageWidth, rightPageY + rightPageHeight + cropOffset + cropLength); } } async function renderPageToImage(pageIndex) { return new Promise((resolve) => { const tempCanvas = document.createElement('canvas'); const tempFabric = new fabric.Canvas(tempCanvas, { width: mmToPx(pageFormat.width), height: mmToPx(pageFormat.height), backgroundColor: '#ffffff' }); if (pages[pageIndex] && pages[pageIndex].objects) { tempFabric.loadFromJSON(pages[pageIndex].objects, () => { // CORRECTION EXPORT : Supprimer les marges ET les objets miroirs pour éviter la duplication tempFabric.getObjects().forEach(obj => { if (obj.isMargin || obj.isBleed || obj._isSpreadMirror) { tempFabric.remove(obj); } }); // ✨ Assurer la césure sur export si activée dans les objets tempFabric.getObjects().forEach(obj => { if (obj.type === 'textbox') { // Si flag présent -> respecter; sinon heuristique si ancien projet if (obj.enableHyphenation === true || (obj.enableHyphenation === undefined && obj.breakWords && obj.splitByGrapheme)) { obj.enableHyphenation = true; obj.hyphenLanguage = obj.hyphenLanguage || currentHyphenLanguage || 'fr'; if (typeof obj._clearCache === 'function') obj._clearCache(); if (typeof obj.initDimensions === 'function') obj.initDimensions(); if (typeof obj.setCoords === 'function') obj.setCoords(); } } }); tempFabric.renderAll(); const imgData = tempFabric.toDataURL({ format: 'png', quality: 1, multiplier: 5 }); tempFabric.dispose(); resolve(imgData); }); } else { // Page vide tempFabric.renderAll(); const imgData = tempFabric.toDataURL({ format: 'png', quality: 1, multiplier: 5 }); tempFabric.dispose(); resolve(imgData); } }); } async function renderPageToImageWithBleed(pageIndex, position, quality = 'standard', bleedValue = null, forceHyphenation = false) { return new Promise((resolve) => { const actualBleed = bleedValue !== null ? bleedValue : bleed; const bleedPx = mmToPx(actualBleed); const pageWidthPx = mmToPx(pageFormat.width); const pageHeightPx = mmToPx(pageFormat.height); // Créer un canvas avec fond perdu complet const fullCanvasWidth = pageWidthPx + (bleedPx * 2); const fullCanvasHeight = pageHeightPx + (bleedPx * 2); const tempCanvas = document.createElement('canvas'); const tempFabric = new fabric.Canvas(tempCanvas, { width: fullCanvasWidth, height: fullCanvasHeight, backgroundColor: '#ffffff' // 🎯 FIX: Fond blanc }); // Désactiver visuels de sélection/contrôles sur le canvas temporaire tempFabric.selection = false; tempFabric.skipTargetFind = true; tempFabric.renderOnAddRemove = false; // 🎯 FIX CRITIQUE: Dessiner manuellement un rectangle blanc en fond AVANT de charger les objets const whiteRect = new fabric.Rect({ left: 0, top: 0, width: fullCanvasWidth, height: fullCanvasHeight, fill: '#ffffff', selectable: false, evented: false, isBackgroundRect: true // Marqueur pour le supprimer si besoin }); tempFabric.add(whiteRect); tempFabric.sendToBack(whiteRect); if (pages[pageIndex] && pages[pageIndex].objects) { tempFabric.loadFromJSON(pages[pageIndex].objects, () => { // CORRECTION EXPORT : Supprimer les éléments de maquette ET les objets miroirs const objectsToRemove = []; tempFabric.getObjects().forEach(obj => { if (obj.isMargin || obj.isBleed || obj.isTrimBox || obj.isGuide || obj.isManualGuide || obj._isSpreadMirror) { objectsToRemove.push(obj); } // Restaurer l'opacité complète pour l'export (les miroirs étaient à 0.85) if (obj._isSpreadMirror && obj.opacity < 1) { obj.opacity = 1.0; } // Cacher toute bordure/poignées/selection sur export obj.hasBorders = false; obj.hasControls = false; obj.hoverCursor = 'default'; if (obj.isEditing && obj.exitEditing) obj.exitEditing(); }); objectsToRemove.forEach(obj => tempFabric.remove(obj)); // Forcer la césure si demandé (et respecter la langue courante) if (forceHyphenation) { tempFabric.getObjects().forEach(obj => { if (obj.type === 'textbox') { obj.enableHyphenation = true; obj.hyphenLanguage = obj.hyphenLanguage || (typeof currentHyphenLanguage !== 'undefined' ? currentHyphenLanguage : 'fr'); if (typeof obj._clearCache === 'function') obj._clearCache(); if (typeof obj.initDimensions === 'function') obj.initDimensions(); if (typeof obj.setCoords === 'function') obj.setCoords(); } }); } // S'assurer que le fond blanc reste en arrière-plan tempFabric.sendToBack(whiteRect); // Anti-hairline: nettoyer traits trop fins et arrondir positions tempFabric.getObjects().forEach(o => { if (o.stroke && (o.strokeWidth === 0 || o.strokeWidth < 0.5)) { o.stroke = 'transparent'; o.strokeWidth = 0; } if (typeof o.left === 'number') o.left = Math.round(o.left) + 0.01; if (typeof o.top === 'number') o.top = Math.round(o.top) + 0.01; o.setCoords(); }); tempFabric.renderAll(); // Utilisation de la fonction globale (optimisé) const multiplier = getQualityMultiplier(quality); // 🎯 FIX: Toujours utiliser PNG pour garantir un fond blanc parfait const fullImgData = tempFabric.toDataURL({ format: 'png', quality: 1, multiplier: multiplier, enableRetinaScaling: false }); tempFabric.dispose(); // Créer un nouveau canvas pour rogner selon la position const img = new Image(); img.onload = () => { let cropCanvas, cropX, cropWidth; if (position === 'left') { // Page gauche : garder fond perdu à gauche, supprimer à droite cropWidth = (pageWidthPx + bleedPx) * multiplier; cropX = 0; } else { // Page droite : garder fond perdu à droite, supprimer à gauche cropWidth = (pageWidthPx + bleedPx) * multiplier; cropX = bleedPx * multiplier; // Commencer après le fond perdu gauche } cropCanvas = document.createElement('canvas'); cropCanvas.width = cropWidth; cropCanvas.height = img.height; // Hauteur complète avec fonds perdu haut et bas const ctx = cropCanvas.getContext('2d'); // 🎯 FIX CRITIQUE: Remplir le canvas avec du blanc AVANT de dessiner l'image ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, cropCanvas.width, cropCanvas.height); ctx.drawImage(img, cropX, 0, cropWidth, img.height, 0, 0, cropWidth, img.height); // 🚀 Forcer PNG pour éviter barres noires/halos aux bords const outputFormat = 'image/png'; const outputQuality = 1; const croppedImgData = cropCanvas.toDataURL(outputFormat, outputQuality); resolve(croppedImgData); }; img.src = fullImgData; }); } else { // Page vide tempFabric.renderAll(); // 🔧 Utilisation de la fonction globale (optimisé) const multiplier = getQualityMultiplier(quality); const imgData = tempFabric.toDataURL({ format: 'png', quality: 1, multiplier: multiplier }); tempFabric.dispose(); resolve(imgData); } }); } // Détermine si une page donnée fait partie d'un spread (double page) // MODIFIÉ : Utilise maintenant le viewMode actif de l'interface principale function isSpreadPage(index) { // Si on est en mode page simple, aucune page n'est un spread if (viewMode === 'single') { return false; } // Mode double page : logique de regroupement // Première page (couverture) : toujours simple if (index === 0) return false; // Dernière page : simple si nombre pair de pages if (index === pages.length - 1 && pages.length % 2 === 0) return false; // Toutes les autres pages internes : spreads return true; } // Chemin de fer - Modal et drag & drop des pages function showCheminDeFer() { saveAllPages(); const modal = document.getElementById('cheminDeFerModal'); const grid = document.getElementById('cheminDeFerGrid'); grid.innerHTML = ''; // Parcourir les pages en sautant les spreads déjà traités let i = 0; while (i < pages.length) { const thumb = document.createElement('div'); thumb.className = 'chemin-page-thumb'; thumb.draggable = true; thumb.dataset.pageIndex = i; // Déterminer si c'est une page simple ou double const isSpread = isSpreadPage(i); if (isSpread) { thumb.classList.add('spread-page'); } else { thumb.classList.add('single-page'); } // Créer les containers de page(s) avec numéros intégrés if (isSpread && i + 1 < pages.length) { // DOUBLE PAGE: créer 2 containers côte à côte const leftPageIndex = i; const rightPageIndex = i + 1; // Container page gauche const leftContainer = document.createElement('div'); leftContainer.className = 'chemin-single-page-container left-page'; const leftLabel = document.createElement('div'); leftLabel.className = 'chemin-page-label'; leftLabel.textContent = `${leftPageIndex + 1}`; leftContainer.appendChild(leftLabel); // Container page droite const rightContainer = document.createElement('div'); rightContainer.className = 'chemin-single-page-container right-page'; const rightLabel = document.createElement('div'); rightLabel.className = 'chemin-page-label'; rightLabel.textContent = `${rightPageIndex + 1}`; rightContainer.appendChild(rightLabel); thumb.appendChild(leftContainer); thumb.appendChild(rightContainer); i += 2; // Sauter les deux pages du spread } else { // PAGE SIMPLE: un seul container const pageIndex = i; const container = document.createElement('div'); container.className = 'chemin-single-page-container'; const label = document.createElement('div'); label.className = 'chemin-page-label'; label.textContent = `${pageIndex + 1}`; container.appendChild(label); thumb.appendChild(container); i++; // Page simple } // Événements drag & drop thumb.addEventListener('dragstart', handleDragStart); thumb.addEventListener('dragend', handleDragEnd); thumb.addEventListener('dragover', handleDragOver); thumb.addEventListener('drop', handleDrop); thumb.addEventListener('dragenter', handleDragEnter); thumb.addEventListener('dragleave', handleDragLeave); grid.appendChild(thumb); } // Ajouter une zone de dépôt à la fin pour "placer en dernier" const dropEnd = document.createElement('div'); dropEnd.className = 'chemin-drop-end'; dropEnd.textContent = 'Déposer ici pour mettre à la fin →'; dropEnd.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dropEnd.classList.add('active'); }); dropEnd.addEventListener('dragleave', () => dropEnd.classList.remove('active')); dropEnd.addEventListener('drop', (e) => { e.preventDefault(); dropEnd.classList.remove('active'); if (draggedPageIndex == null) return; const targetIndex = pages.length - 1; // position finale (après le dernier élément) if (draggedPageIndex !== targetIndex) { // Sauvegarder AVANT la réorganisation saveAllPages(); const movedPage = pages.splice(draggedPageIndex, 1)[0]; pages.push(movedPage); // Mettre à jour currentPageIndex if (currentPageIndex === draggedPageIndex) { currentPageIndex = pages.length - 1; } else if (draggedPageIndex < currentPageIndex) { currentPageIndex--; } renderAllPages(); saveState(`Page ${draggedPageIndex + 1} déplacée en dernière position`); showCheminDeFer(); } }); grid.appendChild(dropEnd); modal.classList.add('active'); } function closeCheminDeFerModal() { document.getElementById('cheminDeFerModal').classList.remove('active'); } async function generatePageThumbnail(pageIndex) { return new Promise((resolve) => { const tempCanvas = document.createElement('canvas'); const scale = 0.2; // Miniature à 20% de la taille const width = mmToPx(pageFormat.width) * scale; const height = mmToPx(pageFormat.height) * scale; const tempFabric = new fabric.Canvas(tempCanvas, { width: width, height: height, backgroundColor: '#ffffff' }); if (pages[pageIndex] && pages[pageIndex].objects) { tempFabric.loadFromJSON(pages[pageIndex].objects, () => { // Supprimer les éléments de maquette const objectsToRemove = []; tempFabric.getObjects().forEach(obj => { if (obj.isMargin || obj.isBleed || obj.isTrimBox || obj.isGuide || obj.isManualGuide) { objectsToRemove.push(obj); } }); objectsToRemove.forEach(obj => tempFabric.remove(obj)); // Redimensionner tous les objets tempFabric.getObjects().forEach(obj => { obj.scaleX = (obj.scaleX || 1) * scale; obj.scaleY = (obj.scaleY || 1) * scale; obj.left = obj.left * scale; obj.top = obj.top * scale; obj.setCoords(); }); tempFabric.renderAll(); const imgData = tempFabric.toDataURL({ format: 'png', quality: 0.5 }); tempFabric.dispose(); resolve(imgData); }); } else { tempFabric.renderAll(); const imgData = tempFabric.toDataURL({ format: 'png', quality: 0.5 }); tempFabric.dispose(); resolve(imgData); } }); } // Variables pour le drag & drop let draggedElement = null; let draggedPageIndex = null; let dropIndicator = null; function handleDragStart(e) { draggedElement = e.target; draggedPageIndex = parseInt(e.target.dataset.pageIndex); e.target.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; // Créer l'indicateur d'insertion if (!dropIndicator) { dropIndicator = document.createElement('div'); dropIndicator.className = 'chemin-insert-indicator'; } } function handleDragEnd(e) { e.target.classList.remove('dragging'); // Nettoyer tous les indicateurs document.querySelectorAll('.chemin-page-thumb').forEach(thumb => { thumb.classList.remove('drag-over'); const indicator = thumb.querySelector('.chemin-insert-indicator'); if (indicator) { indicator.classList.remove('active'); } }); draggedElement = null; draggedPageIndex = null; } function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; // Montrer l'indicateur d'insertion const targetThumb = e.target.closest('.chemin-page-thumb'); if (targetThumb && targetThumb !== draggedElement) { // Calculer si on est à gauche ou à droite du centre const rect = targetThumb.getBoundingClientRect(); const mouseX = e.clientX; const centerX = rect.left + rect.width / 2; const isLeft = mouseX < centerX; // Ajouter/mettre à jour l'indicateur let indicator = targetThumb.querySelector('.chemin-insert-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'chemin-insert-indicator'; targetThumb.style.position = 'relative'; targetThumb.appendChild(indicator); } indicator.classList.add('active'); indicator.classList.toggle('left', isLeft); indicator.classList.toggle('right', !isLeft); } return false; } function handleDragEnter(e) { const targetThumb = e.target.closest('.chemin-page-thumb'); if (targetThumb && targetThumb !== draggedElement) { targetThumb.classList.add('drag-over'); } } function handleDragLeave(e) { const targetThumb = e.target.closest('.chemin-page-thumb'); if (targetThumb && !targetThumb.contains(e.relatedTarget)) { targetThumb.classList.remove('drag-over'); const indicator = targetThumb.querySelector('.chemin-insert-indicator'); if (indicator) { indicator.classList.remove('active'); } } } function handleDrop(e) { if (e.stopPropagation) { e.stopPropagation(); } const targetThumb = e.currentTarget || e.target.closest?.('.chemin-page-thumb') || e.target; if (targetThumb && targetThumb.classList && targetThumb.classList.contains('chemin-page-thumb')) { targetThumb.classList.remove('drag-over'); // Nettoyer l'indicateur const indicator = targetThumb.querySelector('.chemin-insert-indicator'); if (indicator) { indicator.classList.remove('active'); } } if (draggedElement && targetThumb && targetThumb.classList.contains('chemin-page-thumb')) { const targetPageIndex = parseInt(targetThumb.dataset.pageIndex); // Déterminer la position d'insertion (avant ou après) const rect = targetThumb.getBoundingClientRect(); const mouseX = e.clientX; const centerX = rect.left + rect.width / 2; const insertBefore = mouseX < centerX; let insertIndex = targetPageIndex; if (!insertBefore && targetPageIndex < pages.length - 1) { insertIndex = targetPageIndex + 1; } if (draggedPageIndex !== insertIndex && draggedPageIndex !== insertIndex - 1) { // CORRECTION : Sauvegarder AVANT la réorganisation pour éviter la perte d'objets saveAllPages(); // Réorganiser les pages const movedPage = pages.splice(draggedPageIndex, 1)[0]; // Ajuster l'index si nécessaire if (draggedPageIndex < insertIndex) { insertIndex--; } pages.splice(insertIndex, 0, movedPage); // Mettre à jour l'index de la page courante if (currentPageIndex === draggedPageIndex) { currentPageIndex = insertIndex; } else if (draggedPageIndex < currentPageIndex && insertIndex >= currentPageIndex) { currentPageIndex--; } else if (draggedPageIndex > currentPageIndex && insertIndex <= currentPageIndex) { currentPageIndex++; } // CORRECTION : Re-rendre avec une gestion d'erreur try { renderAllPages(); saveState(`Page ${draggedPageIndex + 1} déplacée vers position ${insertIndex + 1}`); // Mettre à jour l'affichage du chemin de fer showCheminDeFer(); } catch (error) { console.error('Erreur lors du rendu après réorganisation:', error); // Restaurer l'état précédent en cas d'erreur if (history.length > 0) { undo(); } } } } return false; } // Fonction pour ajouter une page depuis le Chemin de fer function addPageFromChemin(type) { saveAllPages(); if (type === 'simple') { // Ajouter une page simple const newPage = { objects: [] }; pages.push(newPage); saveState('Page simple ajoutée'); } else if (type === 'double') { // Vérifier que le mode double page est actif if (viewMode !== 'spread') { alert('Pour créer une double page, veuillez d\'abord activer le mode "Double page" dans l\'interface principale.'); return; } // Ajouter deux pages pour un spread const newPage1 = { objects: [] }; const newPage2 = { objects: [] }; pages.push(newPage1, newPage2); saveState('Double page ajoutée'); } renderAllPages(); showCheminDeFer(); // Rafraîchir le modal } // Détection de la page visible dans le viewport (CORRIGÉE) function updateActivePageFromScroll() { const scrollArea = document.getElementById('canvasScrollArea'); if (!scrollArea) return; const scrollAreaRect = scrollArea.getBoundingClientRect(); const viewportCenterY = scrollAreaRect.top + (scrollAreaRect.height / 2); const pageWrappers = document.querySelectorAll('.page-wrapper'); let closestPageIndex = 0; let closestDistance = Infinity; pageWrappers.forEach((wrapper, index) => { const wrapperRect = wrapper.getBoundingClientRect(); const wrapperCenterY = wrapperRect.top + (wrapperRect.height / 2); // Distance entre le centre de la page et le centre du viewport const distance = Math.abs(wrapperCenterY - viewportCenterY); if (distance < closestDistance) { closestDistance = distance; closestPageIndex = index; } }); // Mettre à jour la page active si elle a changé if (currentPageIndex !== closestPageIndex) { currentPageIndex = closestPageIndex; updatePageIndicator(); updateLayersPanel(); } } // Initialiser l'écouteur de scroll function initScrollListener() { const scrollArea = document.getElementById('canvasScrollArea'); if (scrollArea) { let scrollTimeout; scrollArea.addEventListener('scroll', () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { updateActivePageFromScroll(); ensureWidgetVisible(); // 🎯 Repositionner le widget si nécessaire }, 100); }); } } // Typographie options avancées avec sélection partielle function applyTextStyleToSelection(style, value) { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const obj = activeCanvas.getActiveObject(); if (!obj || obj.type !== 'textbox') return; // Fonction pour toggle les boutons visuellement function toggleButton(style) { const buttonId = { 'bold': 'textBold', 'italic': 'textItalic', 'underline': 'textUnderline', 'superscript': 'textSuperscript', 'subscript': 'textSubscript', 'strikethrough': 'textStrikethrough', 'overline': 'textOverline', 'underlineHighlight': 'textUnderlineHighlight', 'bothLines': 'textBothLines', 'highlight': 'textHighlight', 'shadow': 'textShadow', 'wide': 'textWide' }[style]; if (buttonId) { const btn = document.getElementById(buttonId); if (btn) { btn.classList.toggle('active'); } } } // Reset complet: ignorer la sélection et remettre le style par défaut if (style === 'reset') { try { // Effacer tous les styles inline (exposants, surlignage, gras par caractère, etc.) if (obj.styles) obj.styles = {}; // Propriétés par défaut obj.set({ fontWeight: '400', fontStyle: 'normal', underline: false, linethrough: false, overline: false, textAlign: 'left', charSpacing: 0, lineHeight: 1.16, backgroundColor: '', stroke: null, strokeWidth: 0, fill: '#000', scaleY: 1, skewX: 0, shadow: null }); // Rafraîchir dimensions et coords if (typeof obj._clearCache === 'function') obj._clearCache(); obj._fontSizeMult = 1; if (typeof obj.initDimensions === 'function') obj.initDimensions(); if (typeof obj.setCoords === 'function') obj.setCoords(); // Nettoyer l'état visuel des boutons de style ['textBold','textItalic','textUnderline','textStrikethrough','textOverline','textUnderlineHighlight','textBothLines','textHighlight'].forEach(id => { const b = document.getElementById(id); if (b) b.classList.remove('active'); }); } catch(e) {} activeCanvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Reset typographique'); return; } // Sélection partielle if (obj.selectionStart != null && obj.selectionEnd != null && obj.selectionEnd > obj.selectionStart) { const s = obj.selectionStart, e = obj.selectionEnd; switch(style) { case 'bold': { const styles = obj.getSelectionStyles(s, e); const allBold = styles.length > 0 && styles.every(st => { const fw = String(st.fontWeight ?? obj.fontWeight ?? '400'); return fw === '700' || fw === '800' || fw === '900' || fw.toLowerCase() === 'bold'; }); obj.setSelectionStyles({ fontWeight: allBold ? '400' : '700' }, s, e); toggleButton(style); break; } case 'italic': { const styles = obj.getSelectionStyles(s, e); const allItalic = styles.length > 0 && styles.every(st => (st.fontStyle ?? obj.fontStyle) === 'italic'); obj.setSelectionStyles({ fontStyle: allItalic ? 'normal' : 'italic' }, s, e); toggleButton(style); break; } case 'underline': { const styles = obj.getSelectionStyles(s, e); const allUnder = styles.length > 0 && styles.every(st => !!(st.underline ?? obj.underline)); obj.setSelectionStyles({ underline: !allUnder }, s, e); toggleButton(style); break; } case 'superscript': // Obtenir la taille de police de la sélection actuelle const currentStylesSup = obj.getSelectionStyles(s, e); const baseFontSizeSup = currentStylesSup[0]?.fontSize || obj.fontSize; obj.setSelectionStyles({ fontSize: Math.round(baseFontSizeSup * 0.7), deltaY: -Math.round(baseFontSizeSup * 0.3) }, s, e); toggleButton(style); break; case 'subscript': // Obtenir la taille de police de la sélection actuelle const currentStylesSub = obj.getSelectionStyles(s, e); const baseFontSizeSub = currentStylesSub[0]?.fontSize || obj.fontSize; obj.setSelectionStyles({ fontSize: Math.round(baseFontSizeSub * 0.7), deltaY: Math.round(baseFontSizeSub * 0.3) }, s, e); toggleButton(style); break; case 'uppercase': // Transformer seulement la sélection en majuscules let textUpper = obj.text; let beforeUpper = textUpper.slice(0, s); let midUpper = textUpper.slice(s, e).toUpperCase(); let afterUpper = textUpper.slice(e); obj.text = beforeUpper + midUpper + afterUpper; obj.setSelectionStart(s); obj.setSelectionEnd(s + midUpper.length); break; case 'lowercase': // Transformer seulement la sélection en minuscules let textLower = obj.text; let beforeLower = textLower.slice(0, s); let midLower = textLower.slice(s, e).toLowerCase(); let afterLower = textLower.slice(e); obj.text = beforeLower + midLower + afterLower; obj.setSelectionStart(s); obj.setSelectionEnd(s + midLower.length); break; case 'smallcaps': let text = obj.text; let before = text.slice(0, s); let mid = text.slice(s, e).replace(/[a-z]/g, c => c.toUpperCase()); let after = text.slice(e); obj.text = before + mid + after; obj.setSelectionStyles({ fontSize: Math.round(obj.fontSize * 0.8) }, s, s + mid.length); break; case 'strikethrough': // Toggle: vérifier si déjà appliqué const hasStrike = obj.getSelectionStyles(s, e).some(style => style.linethrough); obj.setSelectionStyles({ linethrough: !hasStrike }, s, e); toggleButton(style); break; case 'overline': const hasOverline = obj.getSelectionStyles(s, e).some(style => style.overline); obj.setSelectionStyles({ overline: !hasOverline }, s, e); toggleButton(style); break; case 'underlineHighlight': const hasUnderlineH = obj.getSelectionStyles(s, e).some(style => style.underline); obj.setSelectionStyles({ underline: !hasUnderlineH }, s, e); toggleButton(style); break; case 'bothLines': const hasBoth = obj.getSelectionStyles(s, e).some(style => style.overline && style.underline); const newOverline = !hasBoth; const newUnderline = !hasBoth; obj.setSelectionStyles({ overline: newOverline, underline: newUnderline }, s, e); toggleButton(style); break; case 'highlight': const hasHighlight = obj.getSelectionStyles(s, e).some(style => style.backgroundColor); obj.setSelectionStyles({ backgroundColor: hasHighlight ? '' : document.getElementById('highlightColor').value }, s, e); toggleButton(style); break; case 'shadow': const hasShadow = obj.getSelectionStyles(s, e).some(style => style.shadow); obj.setSelectionStyles({ shadow: hasShadow ? null : { color: '#333', blur: 5, offsetX: 2, offsetY: 2 } }, s, e); toggleButton(style); break; case 'wide': obj.setSelectionStyles({ charSpacing: (obj.charSpacing || 0) + 100 }, s, e); toggleButton(style); break; case 'stroke': // Toggle stroke sur la sélection const hasStrokeSelection = obj.getSelectionStyles(s, e).some(style => style.stroke && style.strokeWidth > 0); obj.setSelectionStyles({ stroke: hasStrokeSelection ? '' : '#222', strokeWidth: hasStrokeSelection ? 0 : 1 }, s, e); break; case 'outline': // Toggle outline sur la sélection const hasOutlineSelection = obj.getSelectionStyles(s, e).some(style => style.fill === '#fff' && style.stroke === '#222'); obj.setSelectionStyles({ fill: hasOutlineSelection ? '#000' : '#fff', stroke: hasOutlineSelection ? '' : '#222', strokeWidth: hasOutlineSelection ? 0 : 2 }, s, e); break; } } else { // Tout le texte avec toggle switch(style) { case 'bold': { const current = String(obj.fontWeight || '400'); const isBold = current === '700' || current === '800' || current === '900' || current.toLowerCase() === 'bold'; obj.set({ fontWeight: isBold ? '400' : '700' }); toggleButton(style); break; } case 'italic': { const isItalic = obj.fontStyle === 'italic'; obj.set({ fontStyle: isItalic ? 'normal' : 'italic' }); toggleButton(style); break; } case 'underline': { const isUnderline = obj.underline === true; obj.set({ underline: !isUnderline }); toggleButton(style); break; } case 'superscript': obj.set({ fontSize: Math.round(obj.fontSize * 0.7), top: obj.top - Math.round(obj.fontSize * 0.3) }); toggleButton(style); break; case 'subscript': obj.set({ fontSize: Math.round(obj.fontSize * 0.7), top: obj.top + Math.round(obj.fontSize * 0.3) }); toggleButton(style); break; case 'uppercase': obj.set({ text: obj.text.toUpperCase() }); break; case 'lowercase': obj.set({ text: obj.text.toLowerCase() }); break; case 'smallcaps': obj.set({ text: obj.text.replace(/[a-z]/g, c => c.toUpperCase()), fontSize: Math.round(obj.fontSize * 0.8) }); break; case 'strikethrough': // Toggle strikethrough obj.set({ linethrough: !obj.linethrough }); toggleButton(style); break; case 'overline': // Toggle overline obj.set({ overline: !obj.overline }); toggleButton(style); break; case 'underlineHighlight': // Toggle underline obj.set({ underline: !obj.underline }); toggleButton(style); break; case 'bothLines': // Toggle both overline and underline const currentOverline = obj.overline; const currentUnderline = obj.underline; const hasBothFull = currentOverline && currentUnderline; obj.set({ overline: !hasBothFull, underline: !hasBothFull }); toggleButton(style); break; case 'highlight': // Toggle highlight const currentBg = obj.backgroundColor; obj.set({ backgroundColor: currentBg ? '' : document.getElementById('highlightColor').value }); toggleButton(style); break; case 'shadow': // Toggle shadow obj.set({ shadow: obj.shadow ? null : { color: '#333', blur: 5, offsetX: 2, offsetY: 2 } }); toggleButton(style); break; case 'wide': obj.set({ charSpacing: (obj.charSpacing || 0) + 100 }); toggleButton(style); break; case 'stroke': // Toggle stroke const hasStroke = obj.stroke && obj.strokeWidth > 0; obj.set({ stroke: hasStroke ? '' : '#222', strokeWidth: hasStroke ? 0 : 1 }); break; case 'outline': // Toggle outline const hasOutline = obj.fill === '#fff' && obj.stroke === '#222'; obj.set({ fill: hasOutline ? '#000' : '#fff', stroke: hasOutline ? '' : '#222', strokeWidth: hasOutline ? 0 : 2 }); break; case 'reset': obj.set({ fontWeight: '400', fontStyle: 'normal', underline: false, linethrough: false, overline: false, textAlign: 'left', charSpacing: 0, lineHeight: 1.16, backgroundColor: '', stroke: null, strokeWidth: 0, fill: '#000', scaleY: 1, skewX: 0, shadow: null }); break; } } activeCanvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Style typographique appliqué'); } // Alignement entre objets (robuste, via bounding boxes) function alignSelectedObjects(direction) { const canvas = getActiveCanvas(); if (!canvas) return; const objs = canvas.getActiveObjects(); if (!objs || objs.length < 2) return; // Calcul sur bounding boxes pour respecter rotation/échelle/origine const bbs = objs.map(o => o.getBoundingRect(true, true)); const minX = Math.min(...bbs.map(bb => bb.left)); const maxX = Math.max(...bbs.map(bb => bb.left + bb.width)); const minY = Math.min(...bbs.map(bb => bb.top)); const maxY = Math.max(...bbs.map(bb => bb.top + bb.height)); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; objs.forEach(obj => { const bb = obj.getBoundingRect(true, true); let dx = 0, dy = 0; switch(direction) { case 'left': dx = minX - bb.left; obj.left += dx; break; case 'center': dx = centerX - (bb.left + bb.width / 2); obj.left += dx; break; case 'right': dx = (maxX - bb.width) - bb.left; obj.left += dx; break; case 'top': dy = minY - bb.top; obj.top += dy; break; case 'middle': dy = centerY - (bb.top + bb.height / 2); obj.top += dy; break; case 'bottom': dy = (maxY - bb.height) - bb.top; obj.top += dy; break; } obj.setCoords(); }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Alignement entre objets'); } // Répartition entre objets (égaliser les écarts) — style Figma function distributeSelectedObjects(mode) { const canvas = getActiveCanvas(); if (!canvas) return; const objs = canvas.getActiveObjects(); if (!objs || objs.length < 3) { alert('Vous devez sélectionner au moins 3 objets pour utiliser la distribution. Les objets extrêmes conserveront leur position.'); return; } // Utiliser les bounding boxes absolues (gèrent rotation, échelle, origine) const items = objs.map(o => { const bb = o.getBoundingRect(true, true); return { obj: o, bbLeft: bb.left, bbTop: bb.top, bbWidth: bb.width, bbHeight: bb.height }; }); if (mode === 'horizontal') { // Trier par bord gauche items.sort((a, b) => a.bbLeft - b.bbLeft); const first = items[0]; const last = items[items.length - 1]; const leftEdge = first.bbLeft; const rightEdge = last.bbLeft + last.bbWidth; // Somme des largeurs des objets intermédiaires const inner = items.slice(1, -1); const innerWidth = inner.reduce((s, it) => s + it.bbWidth, 0); // Espace total entre le bord D du 1er et le bord G du dernier, moins largeurs intermédiaires const available = (rightEdge - (leftEdge + first.bbWidth)) - innerWidth; const gap = available / (items.length - 1); // Positionnement: on vise des bbLeft cibles; convertir en déplacement objet via décalage let targetLeft = leftEdge + first.bbWidth + gap; inner.forEach(it => { const currentBB = it.obj.getBoundingRect(true, true); const dx = targetLeft - currentBB.left; // rapprocher bord gauche BB au target it.obj.left += dx; it.obj.setCoords(); targetLeft += it.bbWidth + gap; }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Répartition horizontale'); } else if (mode === 'vertical') { // Trier par bord haut items.sort((a, b) => a.bbTop - b.bbTop); const first = items[0]; const last = items[items.length - 1]; const topEdge = first.bbTop; const bottomEdge = last.bbTop + last.bbHeight; const inner = items.slice(1, -1); const innerHeight = inner.reduce((s, it) => s + it.bbHeight, 0); const available = (bottomEdge - (topEdge + first.bbHeight)) - innerHeight; const gap = available / (items.length - 1); let targetTop = topEdge + first.bbHeight + gap; inner.forEach(it => { const currentBB = it.obj.getBoundingRect(true, true); const dy = targetTop - currentBB.top; it.obj.top += dy; it.obj.setCoords(); targetTop += it.bbHeight + gap; }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Répartition verticale'); } else if (mode === 'h-center') { // Répartition par centres horizontaux // Trier par centreX const enriched = items.map(it => ({ ...it, centerX: it.bbLeft + it.bbWidth / 2 })).sort((a, b) => a.centerX - b.centerX); const first = enriched[0]; const last = enriched[enriched.length - 1]; const leftCenter = first.centerX; const rightCenter = last.centerX; const gap = (rightCenter - leftCenter) / (enriched.length - 1); let targetCenter = leftCenter + gap; // Déplacer les intermédiaires pour atteindre targetCenter enriched.slice(1, -1).forEach(it => { const currentBB = it.obj.getBoundingRect(true, true); const currentCenterX = currentBB.left + currentBB.width / 2; const dx = targetCenter - currentCenterX; it.obj.left += dx; it.obj.setCoords(); targetCenter += gap; }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Répartition centres horizontaux'); } else if (mode === 'v-center') { // Répartition par centres verticaux const enriched = items.map(it => ({ ...it, centerY: it.bbTop + it.bbHeight / 2 })).sort((a, b) => a.centerY - b.centerY); const first = enriched[0]; const last = enriched[enriched.length - 1]; const topCenter = first.centerY; const bottomCenter = last.centerY; const gap = (bottomCenter - topCenter) / (enriched.length - 1); let targetCenter = topCenter + gap; enriched.slice(1, -1).forEach(it => { const currentBB = it.obj.getBoundingRect(true, true); const currentCenterY = currentBB.top + currentBB.height / 2; const dy = targetCenter - currentCenterY; it.obj.top += dy; it.obj.setCoords(); targetCenter += gap; }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Répartition centres verticaux'); } } // Tidy: égalise les écarts sans figer les extrêmes et recentre l'ensemble function tidySelectedObjects(mode) { const canvas = getActiveCanvas(); if (!canvas) return; const objs = canvas.getActiveObjects(); if (!objs || objs.length < 3) { alert('Tidy nécessite au moins 3 objets sélectionnés.'); return; } const items = objs.map(o => { const bb = o.getBoundingRect(true, true); return { obj: o, bbLeft: bb.left, bbTop: bb.top, bbWidth: bb.width, bbHeight: bb.height }; }); // Écart fixe optionnel (0 = auto) const tidyGapInput = document.getElementById('tidyGap'); const tidyUnitSelect = document.getElementById('tidyUnit'); const rawGap = tidyGapInput ? Math.max(0, parseFloat(tidyGapInput.value) || 0) : 0; const unit = tidyUnitSelect ? tidyUnitSelect.value : 'px'; if (mode === 'horizontal') { // Trier par bord gauche items.sort((a, b) => a.bbLeft - b.bbLeft); const totalWidth = items.reduce((s, it) => s + it.bbWidth, 0); const firstLeft = items[0].bbLeft; const lastRight = items[items.length - 1].bbLeft + items[items.length - 1].bbWidth; const span = lastRight - firstLeft; // Convertir l'écart en pixels selon l'unité choisie let fixedGapPx = rawGap; if (unit === 'mm') fixedGapPx = mmToPx(rawGap); else if (unit === 'cm') fixedGapPx = mmToPx(rawGap * 10); else if (unit === '%') fixedGapPx = (span * rawGap) / 100; const gap = rawGap > 0 ? fixedGapPx : (span - totalWidth) / (items.length - 1); // Calculer centres cibles en repartissant uniformément et RECENTRER autour du milieu initial const originalCenter = (firstLeft + lastRight) / 2; let currentLeft = firstLeft; const targetLefts = items.map(it => { const tl = currentLeft; currentLeft += it.bbWidth + gap; return tl; }); // Si gap fixe, recalculer la largeur totale et recentrer par rapport au centre initial const newFirstLeft = targetLefts[0]; const newLastRight = targetLefts[targetLefts.length - 1] + items[items.length - 1].bbWidth; const newCenter = (newFirstLeft + newLastRight) / 2; const shift = originalCenter - newCenter; // Appliquer déplacements items.forEach((it, idx) => { const target = targetLefts[idx] + shift; const currentBB = it.obj.getBoundingRect(true, true); const dx = target - currentBB.left; it.obj.left += dx; it.obj.setCoords(); }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Tidy horizontal'); } else if (mode === 'vertical') { // Trier par bord haut items.sort((a, b) => a.bbTop - b.bbTop); const totalHeight = items.reduce((s, it) => s + it.bbHeight, 0); const firstTop = items[0].bbTop; const lastBottom = items[items.length - 1].bbTop + items[items.length - 1].bbHeight; const span = lastBottom - firstTop; // Convertir l'écart en pixels selon l'unité choisie let fixedGapPx = rawGap; if (unit === 'mm') fixedGapPx = mmToPx(rawGap); else if (unit === 'cm') fixedGapPx = mmToPx(rawGap * 10); else if (unit === '%') fixedGapPx = (span * rawGap) / 100; const gap = rawGap > 0 ? fixedGapPx : (span - totalHeight) / (items.length - 1); const originalCenter = (firstTop + lastBottom) / 2; let currentTop = firstTop; const targetTops = items.map(it => { const tt = currentTop; currentTop += it.bbHeight + gap; return tt; }); const newFirstTop = targetTops[0]; const newLastBottom = targetTops[targetTops.length - 1] + items[items.length - 1].bbHeight; const newCenter = (newFirstTop + newLastBottom) / 2; const shift = originalCenter - newCenter; items.forEach((it, idx) => { const target = targetTops[idx] + shift; const currentBB = it.obj.getBoundingRect(true, true); const dy = target - currentBB.top; it.obj.top += dy; it.obj.setCoords(); }); canvas.requestRenderAll(); if (typeof saveState === 'function') saveState('Tidy vertical'); } } // FAQ / Guide utilisateur - ouverture/fermeture function openFaqModal() { // Fermer toutes les sections FAQ avant d'ouvrir le modal document.querySelectorAll('.faq-answer').forEach(a => a.style.display = 'none'); document.getElementById('faqModal').classList.add('active'); } function closeFaqModal() { document.getElementById('faqModal').classList.remove('active'); } // Settings modal controls function openSettings() { const modal = document.getElementById('settingsModal'); // Synchroniser les sélecteurs document.getElementById('settingsLang').value = currentLanguage; document.getElementById('rememberLogin').checked = localStorage.getItem('sp_remember_login') === '1'; // Régénérer les boutons d'unités pour les réglages updateUnitsForLanguage(); // Synchroniser l'unité active const savedUnit = localStorage.getItem('sp_unit') || currentUnit || (unitsPerLanguage[currentLanguage] || ['mm'])[0]; document.querySelectorAll('#settingsUnits .btn').forEach(b => { b.classList.toggle('active', b.dataset.unit === savedUnit); }); // Synchroniser le thème const theme = localStorage.getItem('sp_theme') || 'light'; document.querySelectorAll('#settingsTheme .btn').forEach(b => { b.classList.toggle('active', b.dataset.theme === theme); }); modal.classList.add('active'); } function closeSettings() { document.getElementById('settingsModal').classList.remove('active'); } function applySettings() { // Langue const lang = document.getElementById('settingsLang').value; if (lang !== currentLanguage) { setLanguage(lang); } // Unités const activeUnitBtn = Array.from(document.querySelectorAll('#settingsUnits .btn')).find(b => b.classList.contains('active')); const newUnit = activeUnitBtn ? activeUnitBtn.dataset.unit : (unitsPerLanguage[currentLanguage] || ['mm'])[0]; localStorage.setItem('sp_unit', newUnit); if (typeof currentUnit !== 'undefined') { currentUnit = newUnit; document.querySelectorAll('.unit-btn').forEach(b => b.classList.toggle('active', b.dataset.unit === newUnit)); } // Theme const activeThemeBtn = Array.from(document.querySelectorAll('#settingsTheme .btn')).find(b => b.classList.contains('active')); const theme = activeThemeBtn ? activeThemeBtn.dataset.theme : 'light'; localStorage.setItem('sp_theme', theme); document.documentElement.classList.toggle('theme-dark', theme === 'dark'); // Remember login const remember = document.getElementById('rememberLogin').checked; localStorage.setItem('sp_remember_login', remember ? '1' : '0'); if (remember) localStorage.setItem('sp_login_ok', '1'); else localStorage.removeItem('sp_login_ok'); closeSettings(); } // ==================== AI ASSISTANT FUNCTIONS ==================== const AI_MODELS = { openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'], anthropic: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'], deepseek: ['deepseek-chat', 'deepseek-coder'] }; function openAIModal() { const modal = document.getElementById('aiModal'); if (!modal) { console.error('AI Modal not found!'); return; } const provider = localStorage.getItem('sp_ai_provider') || 'anthropic'; const apiKey = localStorage.getItem('sp_ai_key') || ''; document.getElementById('aiProvider').value = provider; document.getElementById('aiApiKey').value = apiKey; updateAIModelDropdown(provider); const savedModel = localStorage.getItem('sp_ai_model'); if (savedModel && AI_MODELS[provider].includes(savedModel)) { document.getElementById('aiModel').value = savedModel; } modal.classList.add('active'); modal.style.display = 'flex'; } function closeAIModal() { const modal = document.getElementById('aiModal'); if (modal) { modal.classList.remove('active'); modal.style.display = 'none'; } } function updateAIModelDropdown(provider) { const modelSelect = document.getElementById('aiModel'); if (!modelSelect) return; modelSelect.innerHTML = ''; AI_MODELS[provider].forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; modelSelect.appendChild(option); }); const savedModel = localStorage.getItem('sp_ai_model'); if (savedModel && AI_MODELS[provider].includes(savedModel)) { modelSelect.value = savedModel; } } function saveAISettings() { const provider = document.getElementById('aiProvider').value; const model = document.getElementById('aiModel').value; const apiKey = document.getElementById('aiApiKey').value; localStorage.setItem('sp_ai_provider', provider); localStorage.setItem('sp_ai_model', model); localStorage.setItem('sp_ai_key', apiKey); logAI('✅ Paramètres sauvegardés: ' + provider + ' / ' + model); } function testAI() { const provider = document.getElementById('aiProvider').value; const model = document.getElementById('aiModel').value; const apiKey = document.getElementById('aiApiKey').value; if (!apiKey) { logAI('❌ Erreur: Clé API manquante'); return; } logAI('🧪 Test de connexion à ' + provider + '...'); callAI('Réponds juste "OK" si tu me reçois', function(response) { logAI('✅ Test réussi! Réponse: ' + response.substring(0, 100)); }, function(error) { logAI('❌ Test échoué: ' + error); }); } function logAI(message) { const log = document.getElementById('aiLog'); if (!log) return; const timestamp = new Date().toLocaleTimeString('fr-FR'); log.value += '[' + timestamp + '] ' + message + '\n'; log.scrollTop = log.scrollHeight; } function copyPrompt(text) { // Extraire la description après les deux-points si présent const cleaned = text.includes(':') ? text.split(':').slice(1).join(':').trim() : text.trim(); document.getElementById('aiPromptModal').value = cleaned; navigator.clipboard.writeText(cleaned).then(function() { logAI('📋 Prompt copié et collé dans le champ: ' + cleaned.substring(0, 60) + '...'); }).catch(function() { logAI('📋 Prompt copié dans le champ (clipboard non disponible)'); }); } function callAI(prompt, onSuccess, onError) { const provider = document.getElementById('aiProvider').value; const model = document.getElementById('aiModel').value; const apiKey = document.getElementById('aiApiKey').value; if (!apiKey) { if (onError) onError('Clé API manquante'); return; } let body = {}; if (provider === 'anthropic') { body = { apiKey: apiKey, anthropicVersion: '2023-06-01', model: model, max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }; } else if (provider === 'openai') { body = { apiKey: apiKey, model: model, messages: [{ role: 'user', content: prompt }], max_tokens: 2000 }; } else if (provider === 'deepseek') { body = { apiKey: apiKey, model: model, messages: [{ role: 'user', content: prompt }], max_tokens: 2000 }; } fetch('ai-proxy.php?provider=' + provider, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(res) { return res.json(); }) .then(function(data) { let text = ''; if (provider === 'anthropic' && data.content && data.content[0]) { text = data.content[0].text; } else if ((provider === 'openai' || provider === 'deepseek') && data.choices && data.choices[0]) { text = data.choices[0].message.content; } else if (data.error) { throw new Error(data.error.message || JSON.stringify(data.error)); } if (onSuccess) onSuccess(text); }) .catch(function(err) { if (onError) onError(err.message || String(err)); }); } function aiCustomPrompt() { const promptText = document.getElementById('aiPromptModal').value.trim(); if (!promptText) { logAI('❌ Prompt vide'); return; } logAI('🚀 Prompt personnalisé: ' + promptText.substring(0, 60) + '...'); const activeCanvas = getActiveCanvas(); if (!activeCanvas) { logAI('❌ Aucun canvas actif'); return; } // 🎯 FIX CRITIQUE: Déterminer la VRAIE page active let realPageIndex = currentPageIndex; // 🔍 DEBUG: Afficher l'état avant détection console.log('🔍 [PAGE DETECTION] currentPageIndex GLOBAL:', currentPageIndex); console.log('🔍 [PAGE DETECTION] viewMode:', viewMode); console.log('🔍 [PAGE DETECTION] pages.length:', pages.length); console.log('🔍 [PAGE DETECTION] activeCanvas.bleedInfo:', activeCanvas.bleedInfo); // En mode spread, trouver sur quelle page on crée (gauche ou droite) if (viewMode === 'spread' && activeCanvas.bleedInfo && activeCanvas.bleedInfo.isSpread) { const leftIndex = activeCanvas.bleedInfo.leftPageIndex; const rightIndex = activeCanvas.bleedInfo.rightPageIndex; console.log('🔍 [SPREAD MODE] leftIndex:', leftIndex, 'rightIndex:', rightIndex); // Vérifier si currentPageIndex est cohérent avec ce canvas if (currentPageIndex === leftIndex || currentPageIndex === rightIndex) { realPageIndex = currentPageIndex; console.log('✅ [SPREAD MODE] currentPageIndex cohérent, utilisation:', realPageIndex); } else { // currentPageIndex n'est pas cohérent, utiliser leftIndex par défaut realPageIndex = leftIndex; console.log('⚠️ [SPREAD MODE] currentPageIndex incohérent, utilisation page gauche:', realPageIndex); logAI('⚠️ currentPageIndex incohérent, utilisation page gauche par défaut'); } logAI('📍 Spread actif: pages ' + (leftIndex + 1) + '-' + (rightIndex + 1) + ', création sur page ' + (realPageIndex + 1)); } else if (viewMode === 'single') { // En mode single, le canvas doit correspondre à currentPageIndex // Vérifier via bleedInfo.pageIndex si disponible console.log('🔍 [SINGLE MODE] Avant vérification - realPageIndex:', realPageIndex); if (activeCanvas.bleedInfo && activeCanvas.bleedInfo.pageIndex !== undefined) { const canvasPageIndex = activeCanvas.bleedInfo.pageIndex; console.log('🔍 [SINGLE MODE] canvas.bleedInfo.pageIndex:', canvasPageIndex); realPageIndex = canvasPageIndex; if (realPageIndex !== currentPageIndex) { console.log('⚠️ [SINGLE MODE] INCOHÉRENCE! currentPageIndex=' + currentPageIndex + ', canvas.pageIndex=' + realPageIndex); console.log('🔧 [SINGLE MODE] CORRECTION: currentPageIndex mis à jour à', realPageIndex); logAI('⚠️ Incohérence détectée: currentPageIndex=' + currentPageIndex + ', canvas.pageIndex=' + realPageIndex); currentPageIndex = realPageIndex; // Mettre à jour la variable globale } else { console.log('✅ [SINGLE MODE] currentPageIndex et canvas.pageIndex cohérents:', realPageIndex); } } else { console.log('⚠️ [SINGLE MODE] Pas de bleedInfo.pageIndex, utilisation currentPageIndex:', realPageIndex); } logAI('📄 Mode simple: page ' + (realPageIndex + 1)); } // Obtenir le contexte multi-page const totalPages = pages.length; const activePageNum = realPageIndex + 1; // Numéro pour affichage (1-based) console.log('🎯 [FINAL] realPageIndex:', realPageIndex, '| activePageNum:', activePageNum, '| totalPages:', totalPages); logAI('✅ Création sur PAGE ' + activePageNum + '/' + totalPages); // Déterminer le type de page (spread ou simple) let pageTypeInfo = ''; if (viewMode === 'spread' && activeCanvas.bleedInfo && activeCanvas.bleedInfo.isSpread) { const leftPage = activeCanvas.bleedInfo.leftPageIndex + 1; const rightPage = activeCanvas.bleedInfo.rightPageIndex + 1; pageTypeInfo = `Double page spread (pages ${leftPage}-${rightPage})`; } else { pageTypeInfo = `Page simple ${activePageNum}`; } const pageContext = totalPages > 1 ? `CONTEXTE MULTI-PAGE: Document de ${totalPages} pages. Tu travailles sur: ${pageTypeInfo}.` : `CONTEXTE: Document à 1 page unique.`; // Prompt optimisé pour PRINT professionnel (unités mm) const canvasWidth = activeCanvas.width; const canvasHeight = activeCanvas.height; const widthMm = Math.round(canvasWidth * 0.264583); // px vers mm (1px = 0.264583mm à 96dpi) const heightMm = Math.round(canvasHeight * 0.264583); // --- VALIDATEUR D'ELEMENTS (clamps & valeurs sûres) --- const SAFE = { left: 60, top: 60, right: canvasWidth - 60, bottom: canvasHeight - 60 }; const MAXW = Math.max(0, SAFE.right - SAFE.left); const MAXH = Math.max(0, SAFE.bottom - SAFE.top); function toNumber(v, defVal) { const n = typeof v === 'string' ? parseFloat(v) : (typeof v === 'number' ? v : NaN); return Number.isFinite(n) ? n : defVal; } function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); } function clamp01(v, defVal) { return clamp(toNumber(v, defVal), 0, 1); } function sanitizeColor(c, def) { if (typeof c !== 'string') return def; const m = c.trim(); return /^#([0-9A-Fa-f]{6})$/.test(m) ? m.toUpperCase() : def; } function sanitizeElement(el) { if (!el || !el.type) return { ok: false, reason: 'Element sans type' }; const type = String(el.type).toLowerCase(); if (type !== 'rectangle' && type !== 'circle' && type !== 'text') { return { ok: false, reason: 'Type non autorisé: ' + type }; } if (type === 'rectangle') { let width = clamp(toNumber(el.width, 150), 8, MAXW); let height = clamp(toNumber(el.height, 100), 8, MAXH); let left = clamp(toNumber(el.left, SAFE.left), SAFE.left, SAFE.right - width); let top = clamp(toNumber(el.top, SAFE.top), SAFE.top, SAFE.bottom - height); const fill = sanitizeColor(el.fill, '#000000'); const strokeWidth = clamp(toNumber(el.strokeWidth, 0), 0, 20); const stroke = strokeWidth > 0 ? sanitizeColor(el.stroke || '#000000', '#000000') : ''; const rx = clamp(toNumber(el.rx, 0), 0, 200); const ry = clamp(toNumber(el.ry, 0), 0, 200); const opacity = clamp01(el.opacity, 1); return { ok: true, value: { type, left, top, width, height, fill, stroke, strokeWidth, rx, ry, opacity } }; } if (type === 'circle') { let radius = clamp(toNumber(el.radius, 50), 5, Math.floor(Math.min(MAXW, MAXH) / 2)); let left = clamp(toNumber(el.left, SAFE.left), SAFE.left, SAFE.right - (radius * 2)); let top = clamp(toNumber(el.top, SAFE.top), SAFE.top, SAFE.bottom - (radius * 2)); const fill = sanitizeColor(el.fill, '#000000'); const strokeWidth = clamp(toNumber(el.strokeWidth, 0), 0, 20); const stroke = strokeWidth > 0 ? sanitizeColor(el.stroke || '#000000', '#000000') : ''; const opacity = clamp01(el.opacity, 1); return { ok: true, value: { type, left, top, radius, fill, stroke, strokeWidth, opacity } }; } // text const text = (typeof el.text === 'string' && el.text.trim().length > 0) ? el.text : 'Texte'; let width = clamp(toNumber(el.width, 300), 50, MAXW); let fontSize = clamp(toNumber(el.fontSize, 12), 8, 96); let left = clamp(toNumber(el.left, SAFE.left), SAFE.left, SAFE.right - width); // réserver un petit espace vertical basé sur la taille de police let minTextH = Math.max(Math.round(fontSize * 1.4), 12); let top = clamp(toNumber(el.top, SAFE.top), SAFE.top, SAFE.bottom - minTextH); const fill = sanitizeColor(el.fill, '#000000'); const opacity = clamp01(el.opacity, 1); const fontFamily = (el.fontFamily && String(el.fontFamily)) || 'IBM Plex Sans'; const fontWeight = (el.fontWeight && String(el.fontWeight)) || 'normal'; const fontStyle = (el.fontStyle && String(el.fontStyle)) || 'normal'; const textAlign = (el.textAlign && String(el.textAlign)) || 'left'; return { ok: true, value: { type, left, top, width, text, fontSize, fill, opacity, fontFamily, fontWeight, fontStyle, textAlign } }; } // Hyphénation heuristique (soft hyphens) pour améliorer la justification function softHyphenateWord(word) { if (!word || word.length < 10) return word; const vowels = 'aeiouyàâäéèêëîïôöùûüAEIOUY'; const consonants = 'bcdfghjklmnpqrstvwxzçBCDFGHJKLMNPQRSTVWXZÇ'; // Couper après une voyelle si suivie de consonne(s) const pieces = []; let start = 0; for (let i = 1; i < word.length - 2; i++) { const a = word[i - 1], b = word[i]; if (vowels.includes(a) && consonants.includes(b)) { if (i - start >= 3) { pieces.push(word.slice(start, i)); start = i; } } } if (start < word.length) pieces.push(word.slice(start)); return pieces.join('\u00AD'); } function softHyphenateText(text) { return String(text).replace(/([A-Za-zÀ-ÖØ-öø-ÿ]{10,})/g, (m) => softHyphenateWord(m)); } const enrichedPrompt = `Tu es un designer PRINT professionnel opérant dans SUPERPRINT (logiciel PAO multi‑pages). OBJECTIF • Générer des éléments graphiques visibles et bien positionnés pour la page active uniquement. • Ne PAS créer de pages toi‑même dans les éléments. Tu peux seulement signaler un besoin de pages via "targetPages" (nombre) ; l'application créera les pages manquantes. CONTEXTE LOGICIEL • Format actuel: ${widthMm}×${heightMm}mm (${canvasWidth}×${canvasHeight}px) • Page active: ${activePageNum}/${totalPages} (${pageTypeInfo}) • Zone sûre (marges print 15mm): left 60 → ${canvasWidth - 60}px, top 60 → ${canvasHeight - 60}px • Système: coordonnées en px depuis le coin supérieur gauche. Les couleurs sont au format hex (#RRGGBB). INSTRUCTION UTILISATEUR "${promptText}" SORTIE OBLIGATOIRE • Réponds UNIQUEMENT avec un JSON valide, sans texte autour, sans commentaires, sans blocs de code. • Schéma strict: { "elements": [ // 0 à N éléments parmi: rectangle | circle | text ], "targetPages": null | number // si l'utilisateur demande p.ex. "livre 8 pages", mettre 8 ; sinon null } CONTRAINTES POUR CHAQUE ÉLÉMENT • type: "rectangle" | "circle" | "text" • positions/taille: nombres en px (pas de mm, pas de "%"). Respecter: left ∈ [60, ${canvasWidth - 60}], top ∈ [60, ${canvasHeight - 60}] • rectangle: {left, top, width, height, fill, (stroke, strokeWidth, rx, ry, opacity)} • circle: {left, top, radius, fill, (stroke, strokeWidth, opacity)} • text: {left, top, text, width, fontSize, fill, (fontFamily, fontWeight, fontStyle, textAlign, opacity)} • Polices conseillées: Inter, IBM Plex Sans, Bebas Neue, Poppins • Valeurs par défaut (si rien précisé): fill="#000000", opacity=1, fontSize=16, width=300 • Ne pas renvoyer de propriétés inconnues. Ne pas renvoyer d'éléments hors page. EXEMPLE MINIMAL VALIDE { "elements": [ {"type":"rectangle","left":80,"top":100,"width":${Math.max(120, canvasWidth - 160)},"height":220,"fill":"#2C3E50"}, {"type":"text","left":120,"top":180,"text":"Titre","fontSize":38,"fill":"#FFFFFF","fontFamily":"Bebas Neue","fontWeight":"bold","width":${Math.max(180, canvasWidth - 240)}} ], "targetPages": ${promptText.match(/(\d+)\s*pages?/i) ? parseInt(promptText.match(/(\d+)\s*pages?/i)[1]) : 'null'} } NOTE MULTI‑PAGE • Si l'utilisateur demande plus de pages (p.ex. "livre 8 pages") et que le document actuel a ${totalPages} page(s), définis "targetPages"=8. Ne crée PAS de pages dans "elements". +RAPPEL +• Sortie = JSON strict uniquement. Aucune prose, aucun bloc code. +• Toutes les dimensions/positions doivent être en px et visibles sur la page.`; console.log('🔍 [AI CALL] Prompt enrichi prêt, longueur:', enrichedPrompt.length); console.log('🔍 [AI CALL] Extrait du prompt (100 premiers chars):', enrichedPrompt.substring(0, 100) + '...'); logAI('📡 Envoi à l\'IA pour génération...'); callAI(enrichedPrompt, function(response) { console.log('🔍 [AI RESPONSE] Réponse brute reçue, longueur:', response.length); console.log('🔍 [AI RESPONSE] Extrait (200 premiers chars):', response.substring(0, 200) + '...'); logAI('📥 Réponse reçue, parsing...'); try { // Extraire le JSON de la réponse console.log('🔍 [JSON PARSE] Tentative d\'extraction du JSON...'); let jsonMatch = response.match(/\{[\s\S]*"elements"[\s\S]*\}/); if (!jsonMatch) { console.log('⚠️ [JSON PARSE] Pas de JSON trouvé avec pattern standard, essai avec bloc code...'); // Essayer de trouver un bloc de code JSON jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/); if (jsonMatch) { jsonMatch = [jsonMatch[1]]; console.log('✅ [JSON PARSE] JSON trouvé dans bloc code'); } } else { console.log('✅ [JSON PARSE] JSON trouvé avec pattern standard'); } if (!jsonMatch) { console.error('❌ [JSON PARSE] Aucun JSON trouvé dans la réponse!'); console.log('📄 [JSON PARSE] Réponse complète:', response); throw new Error('Pas de JSON trouvé dans la réponse'); } console.log('🔍 [JSON PARSE] JSON extrait (300 premiers chars):', jsonMatch[0].substring(0, 300) + '...'); const data = JSON.parse(jsonMatch[0]); console.log('✅ [JSON PARSE] JSON parsé avec succès:', data); if (!data.elements || !Array.isArray(data.elements)) { throw new Error('Format JSON invalide'); } logAI('✅ ' + data.elements.length + ' élément(s) à créer'); // 📄 DEBUG: Analyse des pages demandées vs existantes const currentTotalPages = pages.length; // Tolérer string ou number let targetPages = null; if (typeof data.targetPages === 'number') { targetPages = Math.max(1, Math.floor(data.targetPages)); } else if (typeof data.targetPages === 'string') { const parsed = parseInt(data.targetPages, 10); if (!isNaN(parsed)) targetPages = Math.max(1, Math.floor(parsed)); } console.log('🔍 [AI RESPONSE] targetPages:', targetPages, '| currentTotalPages:', currentTotalPages); // 🚨 VÉRIFIER SI L'IA SIGNALE DES PAGES MANQUANTES → CRÉER AUTOMATIQUEMENT if (targetPages && targetPages > currentTotalPages) { const missingPages = targetPages - currentTotalPages; logAI('📄 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); logAI('📄 CRÉATION AUTOMATIQUE DE PAGES'); logAI('📄 Document actuel: ' + currentTotalPages + ' page(s)'); logAI('📄 Document demandé: ' + targetPages + ' pages'); logAI('📄 Création de: ' + missingPages + ' page(s) supplémentaire(s)'); logAI('📄 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); // Sauvegarder l'état actuel avant d'ajouter des pages saveAllPages(); // Créer les pages manquantes AVEC contenu automatique const promptLower = promptText.toLowerCase(); const isBook = promptLower.includes('livre') || promptLower.includes('book'); const isCatalogue = promptLower.includes('catalogue') || promptLower.includes('catalog'); const isDossier = promptLower.includes('dossier') || promptLower.includes('présentation'); for (let i = 0; i < missingPages; i++) { const newPage = { objects: [] }; pages.push(newPage); const pageNum = pages.length; console.log('✅ Page ' + pageNum + ' créée automatiquement'); logAI('✅ Page ' + pageNum + ' créée'); } logAI('✅ ' + missingPages + ' page(s) ajoutée(s) avec succès!'); logAI('🎨 Remplissage automatique des pages...'); // REMPLIR automatiquement chaque page avec du contenu (DANS pages[].objects) for (let pageIdx = currentTotalPages; pageIdx < targetPages; pageIdx++) { const pageNum = pageIdx + 1; let pageElements = []; if (pageNum === 2 && (isBook || isDossier || isCatalogue)) { // Page 2 = Sommaire pageElements = [ {"type":"text","left":80,"top":80,"text":"SOMMAIRE","fontSize":32,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":125,"width":435,"height":2,"fill":"#E74C3C"}, {"type":"text","left":80,"top":160,"text":"1. Introduction","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":190,"text":"2. Chapitre 1","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":220,"text":"3. Chapitre 2","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":480,"top":780,"text":"Page 2","fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } else if (pageNum === targetPages) { // Dernière page = Contact/Fin pageElements = [ {"type":"text","left":200,"top":350,"text":"FIN","fontSize":48,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":200}, {"type":"text","left":480,"top":780,"text":"Page " + pageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } else { // Pages intermédiaires = Contenu const chapterNum = pageNum - 2; pageElements = [ {"type":"text","left":80,"top":80,"text":"Chapitre " + chapterNum,"fontSize":28,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":120,"width":435,"height":1,"fill":"#E74C3C"}, {"type":"text","left":80,"top":145,"text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.","fontSize":11,"fill":"#333333","fontFamily":"Inter","width":435}, {"type":"rectangle","left":160,"top":280,"width":200,"height":150,"fill":"#BDBDBD"}, {"type":"text","left":210,"top":345,"text":"Image 200x150","fontSize":12,"fill":"#666666","fontFamily":"Inter","width":100}, {"type":"text","left":480,"top":780,"text":"Page " + pageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } // Sauvegarder dans pages[].objects (pas directement dans canvas) pageElements.forEach(function(elem) { try { let fabricObj = null; if (elem.type === 'rectangle') { fabricObj = new fabric.Rect({ left: elem.left, top: elem.top, width: elem.width, height: elem.height, fill: elem.fill, stroke: elem.stroke || '', strokeWidth: elem.strokeWidth || 0 }); } else if (elem.type === 'circle') { fabricObj = new fabric.Circle({ left: elem.left, top: elem.top, radius: elem.radius, fill: elem.fill, stroke: elem.stroke || '', strokeWidth: elem.strokeWidth || 0 }); } else if (elem.type === 'text') { fabricObj = new fabric.Textbox(elem.text, { left: elem.left, top: elem.top, width: elem.width || 300, fontSize: elem.fontSize, fill: elem.fill, fontFamily: elem.fontFamily, fontWeight: elem.fontWeight || 'normal' }); } if (fabricObj) { // Sauvegarder dans la structure pages[] if (!pages[pageIdx].objects) pages[pageIdx].objects = []; pages[pageIdx].objects.push(fabricObj.toObject()); } } catch(e) { console.error('Erreur création élément page ' + pageNum + ':', e); } }); logAI(' ✓ Page ' + pageNum + ' remplie (' + pageElements.length + ' éléments)'); } // Rafraîchir APRÈS avoir rempli toutes les pages renderAllPages(); saveAllPages(); showCheminDeFer(); logAI('🎉 Document complet de ' + targetPages + ' pages créé!'); saveState(targetPages + ' pages avec contenu automatique'); } if (data.description) { logAI('💡 ' + data.description); } // 🚨 VÉRIFIER SI L'IA A OUBLIÉ DE CRÉER DES ÉLÉMENTS if (!data.elements || data.elements.length === 0) { console.warn('⚠️ [AI] Aucun élément reçu! Analyse du prompt pour création intelligente...'); logAI('⚠️ L\'IA n\'a pas renvoyé de JSON valide. Création automatique...'); // Analyser le prompt pour créer une composition adaptée const promptLower = promptText.toLowerCase(); const isCover = activePageNum === 1 || promptLower.includes('couverture') || promptLower.includes('cover'); const hasSommaire = promptLower.includes('sommaire') || promptLower.includes('table'); const hasColumns = promptLower.includes('colonnes') || promptLower.includes('columns'); const hasQR = promptLower.includes('qr') || promptLower.includes('code'); const hasContact = promptLower.includes('contact') || promptLower.includes('coordonnées'); if (isCover && activePageNum === 1) { // COUVERTURE logAI('📄 Création couverture...'); data.elements = [ {"type":"rectangle","left":60,"top":60,"width":475,"height":450,"fill":"#2C3E50"}, {"type":"text","left":100,"top":220,"text":"DOSSIER DE\\nPRÉSENTATION","fontSize":44,"fill":"#FFFFFF","fontFamily":"Bebas Neue","fontWeight":"bold","width":380}, {"type":"rectangle","left":100,"top":320,"width":280,"height":3,"fill":"#E74C3C"}, {"type":"text","left":100,"top":340,"text":"Entreprise • 2025","fontSize":16,"fill":"#FFFFFF","fontFamily":"Inter","width":380}, {"type":"circle","left":450,"top":650,"radius":50,"fill":"#E74C3C"} ]; } else if (hasSommaire || (activePageNum === 2 && totalPages > 2)) { // SOMMAIRE logAI('📋 Création sommaire...'); data.elements = [ {"type":"text","left":80,"top":80,"text":"SOMMAIRE","fontSize":32,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":125,"width":435,"height":2,"fill":"#E74C3C"}, {"type":"text","left":80,"top":160,"text":"1. Introduction","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":190,"text":"2. Notre Expertise","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":220,"text":"3. Services & Produits","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":250,"text":"4. Références Clients","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":280,"text":"5. Contact","fontSize":14,"fill":"#000000","fontFamily":"Inter","width":400}, {"type":"text","left":480,"top":780,"text":"Page " + activePageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } else if (hasContact || hasQR || activePageNum === totalPages) { // PAGE CONTACT logAI('📞 Création page contact...'); data.elements = [ {"type":"text","left":80,"top":80,"text":"CONTACT","fontSize":32,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":125,"width":435,"height":2,"fill":"#E74C3C"}, {"type":"text","left":80,"top":160,"text":"Entreprise XYZ","fontSize":16,"fill":"#000000","fontFamily":"Inter","fontWeight":"bold","width":400}, {"type":"text","left":80,"top":190,"text":"123 Rue Example\\n75000 Paris\\nFrance","fontSize":12,"fill":"#333333","fontFamily":"Inter","width":400}, {"type":"text","left":80,"top":260,"text":"Tel: +33 1 23 45 67 89\\nEmail: contact@exemple.fr","fontSize":12,"fill":"#333333","fontFamily":"Inter","width":400}, {"type":"rectangle","left":80,"top":350,"width":60,"height":60,"fill":"#000000"}, {"type":"text","left":85,"top":370,"text":"QR","fontSize":20,"fill":"#FFFFFF","fontFamily":"Inter","fontWeight":"bold","width":50}, {"type":"text","left":480,"top":780,"text":"Page " + activePageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } else if (hasColumns) { // PAGE CONTENU 2 COLONNES logAI('📰 Création page 2 colonnes...'); data.elements = [ {"type":"text","left":80,"top":80,"text":"Notre Expertise","fontSize":28,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":120,"width":435,"height":1,"fill":"#E74C3C"}, {"type":"text","left":80,"top":145,"text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor.","fontSize":11,"fill":"#333333","fontFamily":"Inter","width":200}, {"type":"text","left":310,"top":145,"text":"Ut labore et dolore magna aliqua. Ut enim ad minim veniam quis nostrud.","fontSize":11,"fill":"#333333","fontFamily":"Inter","width":200}, {"type":"rectangle","left":160,"top":300,"width":200,"height":150,"fill":"#BDBDBD"}, {"type":"text","left":210,"top":365,"text":"Image","fontSize":14,"fill":"#666666","fontFamily":"Inter","width":100}, {"type":"text","left":480,"top":780,"text":"Page " + activePageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } else { // PAGE CONTENU STANDARD logAI('📄 Création page contenu...'); data.elements = [ {"type":"text","left":80,"top":80,"text":"Chapitre " + (activePageNum - 1),"fontSize":28,"fill":"#2C3E50","fontFamily":"Bebas Neue","fontWeight":"bold","width":400}, {"type":"rectangle","left":80,"top":120,"width":435,"height":1,"fill":"#E74C3C"}, {"type":"text","left":80,"top":145,"text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.","fontSize":11,"fill":"#333333","fontFamily":"Inter","width":435}, {"type":"rectangle","left":160,"top":280,"width":200,"height":150,"fill":"#BDBDBD"}, {"type":"text","left":210,"top":345,"text":"Image 200x150","fontSize":12,"fill":"#666666","fontFamily":"Inter","width":100}, {"type":"text","left":480,"top":780,"text":"Page " + activePageNum,"fontSize":10,"fill":"#666666","fontFamily":"Inter"} ]; } logAI('✅ Composition intelligente créée pour page ' + activePageNum); } // Créer les éléments avec validation/sanitization let createdCount = 0; let skippedCount = 0; const skippedNotes = []; data.elements.forEach(function(elem) { try { let fabricObj = null; const norm = sanitizeElement(elem); if (!norm.ok) { skippedCount++; const reason = norm.reason || 'Elément invalide'; skippedNotes.push(reason); console.warn('⏭ Élément ignoré:', reason, elem); return; } const e = norm.value; if (e.type === 'rectangle') { fabricObj = new fabric.Rect({ left: e.left, top: e.top, width: e.width, height: e.height, fill: e.fill, stroke: e.stroke, strokeWidth: e.strokeWidth, rx: e.rx, ry: e.ry, opacity: e.opacity }); } else if (e.type === 'circle') { fabricObj = new fabric.Circle({ left: e.left, top: e.top, radius: e.radius, fill: e.fill, stroke: e.stroke, strokeWidth: e.strokeWidth, opacity: e.opacity }); } else if (e.type === 'text') { // Appliquer hyphénation douce pour justification robuste const hyText = (e.textAlign === 'justify') ? softHyphenateText(e.text) : e.text; fabricObj = new fabric.Textbox(hyText, { left: e.left, top: e.top, width: e.width, fontSize: e.fontSize, fill: e.fill, fontFamily: e.fontFamily, fontWeight: e.fontWeight, fontStyle: e.fontStyle, textAlign: e.textAlign, opacity: e.opacity }); } if (fabricObj) { activeCanvas.add(fabricObj); createdCount++; console.log('✅ Élément créé:', e.type, 'at', e.left, e.top); if (e.description) { logAI(' ✓ ' + e.description); } } else { console.warn('❌ Élément non créé:', elem); } } catch (elemError) { console.error('❌ Erreur création élément:', elemError, elem); logAI(' ⚠️ Erreur élément: ' + elemError.message); } }); if (skippedCount > 0) { const detail = skippedNotes.slice(0, 5).join(' | '); logAI('🧹 Validation: ' + createdCount + ' créé(s), ' + skippedCount + ' ignoré(s)'); if (detail) logAI(' • Motifs: ' + detail + (skippedNotes.length > 5 ? ' ...' : '')); } console.log('🎨 [FINAL] ' + createdCount + ' éléments créés sur le canvas (ignorés: ' + skippedCount + ')'); activeCanvas.renderAll(); saveAllPages(); // IMPORTANT: Sauvegarder dans pages[] saveState('IA: ' + createdCount + ' élément(s) créé(s) page ' + activePageNum); logAI('✨ ' + createdCount + ' élément(s) créé(s) avec succès sur page ' + activePageNum + '!'); // Afficher un message si aucun élément visible if (createdCount === 0) { logAI('⚠️ ATTENTION: Aucun élément créé! L\'IA n\'a pas renvoyé de JSON valide.'); logAI('💡 Essayez un prompt plus simple comme: "crée un titre" ou "couverture catalogue"'); } } catch (parseError) { logAI('⚠️ Impossible de parser la réponse, affichage texte:'); logAI(' ' + response); // Fallback: chercher des codes couleur const hexRegex = /#[0-9A-Fa-f]{6}/g; const colors = response.match(hexRegex); if (colors && colors.length > 0) { logAI('🎨 ' + colors.length + ' couleur(s) trouvée(s): ' + colors.join(', ')); const startX = 50; const startY = 300; const boxSize = 50; const gap = 10; colors.forEach(function(color, index) { const rect = new fabric.Rect({ left: startX + (index * (boxSize + gap)), top: startY, width: boxSize, height: boxSize, fill: color, stroke: '#000', strokeWidth: 1 }); activeCanvas.add(rect); }); activeCanvas.renderAll(); saveState('Couleurs IA ajoutées'); logAI('✨ Couleurs ajoutées automatiquement!'); } } }, function(error) { logAI('❌ Erreur: ' + error); }); } function aiColorSuggestion() { const activeCanvas = getActiveCanvas(); if (!activeCanvas) { logAI('❌ Aucun canvas actif'); return; } const prompt = 'Suggère exactement 5 couleurs hexadécimales pour une maquette print moderne et harmonieuse. Réponds UNIQUEMENT avec les codes hex séparés par des virgules, exemple: #1A2B3C, #4D5E6F, #7A8B9C, #ADBECF, #D0E1F2'; logAI('🎨 Demande de palette couleur...'); callAI(prompt, function(response) { logAI('📥 Réponse: ' + response.substring(0, 100)); // Extraire les codes couleur hex const hexRegex = /#[0-9A-Fa-f]{6}/g; const colors = response.match(hexRegex); if (colors && colors.length > 0) { logAI('✅ ' + colors.length + ' couleurs trouvées: ' + colors.join(', ')); const startX = 50; const startY = 50; const boxSize = 60; const gap = 10; colors.forEach(function(color, index) { const rect = new fabric.Rect({ left: startX + (index * (boxSize + gap)), top: startY, width: boxSize, height: boxSize, fill: color, stroke: '#000', strokeWidth: 1 }); // Ajouter le code couleur en texte const text = new fabric.Text(color, { left: startX + (index * (boxSize + gap)) + boxSize/2, top: startY + boxSize + 10, fontSize: 10, fill: '#000', originX: 'center', fontFamily: 'IBM Plex Mono' }); activeCanvas.add(rect); activeCanvas.add(text); }); activeCanvas.renderAll(); saveState('Palette IA ajoutée'); logAI('✨ Palette ajoutée automatiquement!'); } else { logAI('⚠️ Aucune couleur trouvée dans la réponse'); } }, function(error) { logAI('❌ Erreur: ' + error); }); } function aiLayoutCritique() { const activeCanvas = getActiveCanvas(); if (!activeCanvas) { logAI('❌ Aucun canvas actif'); return; } const objects = activeCanvas.getObjects(); const totalPages = pages.length; const activePageNum = currentPageIndex + 1; let description = totalPages > 1 ? `Analyse cette page ${activePageNum}/${totalPages} (document multi-page) avec ${objects.length} éléments:\n` : `Analyse cette maquette avec ${objects.length} éléments:\n`; // Compter les types d'objets let counts = { text: 0, image: 0, rect: 0, circle: 0, path: 0, other: 0 }; objects.forEach(function(obj) { if (obj.type === 'text' || obj.type === 'textbox' || obj.type === 'i-text') counts.text++; else if (obj.type === 'image') counts.image++; else if (obj.type === 'rect') counts.rect++; else if (obj.type === 'circle') counts.circle++; else if (obj.type === 'path') counts.path++; else counts.other++; }); description += '- ' + counts.text + ' textes\n'; description += '- ' + counts.image + ' images\n'; description += '- ' + counts.rect + ' rectangles\n'; description += '- ' + counts.circle + ' cercles\n'; const multiPageHint = totalPages > 1 ? '\nCONTEXTE: Document multi-page. Pense cohérence entre pages (grille, marges, éléments récurrents).\n' : ''; // Construction d'un inventaire détaillé JSON pour analyse IA const inventory = objects.filter(o => !o.isMargin && !o.isBleed && !o.isGuide && !o.isManualGuide && !o.isTrimBox && !o.isPageBorder).map(o => ({ type: o.type, left: Math.round(o.left || 0), top: Math.round(o.top || 0), width: Math.round(o.width * (o.scaleX || 1) || (o.getScaledWidth ? Math.round(o.getScaledWidth()) : 0)), height: Math.round(o.height * (o.scaleY || 1) || (o.getScaledHeight ? Math.round(o.getScaledHeight()) : 0)), fontSize: o.fontSize || undefined, text: (o.type === 'text' || o.type === 'textbox' || o.type === 'i-text') ? (o.text || '').substring(0,120) : undefined, fill: o.fill || undefined })); const analysisRequest = { page: { number: activePageNum, totalPages: totalPages, canvasWidth: activeCanvas.width, canvasHeight: activeCanvas.height, safeArea: { left:60, top:60, right: activeCanvas.width-60, bottom: activeCanvas.height-60 } }, counts, elements: inventory }; const prompt = `Tu es un expert en mise en page PRINT. Analyse le JSON suivant et retourne STRICTEMENT ce JSON de sortie:\n{\n \"summary\": {\"density\": , \"balance\": , \"hierarchyIssue\": },\n \"warnings\": [],\n \"suggestions\": [ { \"action\": , \"reason\": , \"impact\": } ]\n}\nNe rajoute pas d'explications hors JSON. Voici la page à analyser:\nINPUT_JSON_START\n${JSON.stringify(analysisRequest)}\nINPUT_JSON_END`; logAI('📐 Analyse JSON de la page...'); callAI(prompt, function(response) { // Extraire le JSON propre let jsonBlock = response.match(/\{[\s\S]*\}/); if (!jsonBlock) { logAI('⚠️ Réponse non JSON, affichage brut'); logAI(response.substring(0,400)); return; } let parsed = null; try { parsed = JSON.parse(jsonBlock[0]); } catch(e){ logAI('❌ Parse JSON impossible'); return; } // Validation minimale if (!parsed.summary || !parsed.suggestions) { logAI('⚠️ JSON incomplet reçu'); } // Log structuré logAI('🧾 Résumé: densité=' + (parsed.summary?.density||'?') + ', équilibre=' + (parsed.summary?.balance||'?')); if (Array.isArray(parsed.warnings)) parsed.warnings.forEach(w => logAI('⚠️ ' + w)); if (Array.isArray(parsed.suggestions)) parsed.suggestions.slice(0,5).forEach(s => logAI('➜ ' + s.action + ' (' + s.impact + ')')); // Création d'un panneau synthèse sur le canvas const lines = []; lines.push('ANALYSE PAGE ' + activePageNum + '/' + totalPages); if (parsed.summary){ lines.push('Densité: ' + parsed.summary.density); lines.push('Équilibre: ' + parsed.summary.balance); if (parsed.summary.hierarchyIssue) lines.push('Hiérarchie: ' + parsed.summary.hierarchyIssue); } if (parsed.warnings && parsed.warnings.length){ lines.push('Avertissements:'); parsed.warnings.slice(0,4).forEach(w => lines.push('- ' + w)); } if (parsed.suggestions && parsed.suggestions.length){ lines.push('Suggestions:'); parsed.suggestions.slice(0,5).forEach(s => lines.push('- ' + s.action)); } const panelText = lines.join('\n'); const analysisBox = new fabric.Textbox(panelText, { left: activeCanvas.width - 380, top: 60, width: 320, fontSize: 11, fill: '#000', fontFamily: 'IBM Plex Mono', backgroundColor: '#FFFFFF', stroke: '#000', strokeWidth: 0.5, padding: 10 }); activeCanvas.add(analysisBox); // Ajout badges pour suggestions let badgeY = analysisBox.top + analysisBox.getScaledHeight() + 10; (parsed.suggestions||[]).slice(0,3).forEach((sug, idx) => { const badge = new fabric.Textbox('• ' + (sug.action||'Action') + '\n' + (sug.reason||'Raison'), { left: analysisBox.left, top: badgeY + idx * 70, width: 320, fontSize: 10, fill: '#111', fontFamily: 'Inter', backgroundColor: '#F5F5F5', padding: 8 }); activeCanvas.add(badge); }); activeCanvas.renderAll(); saveState('Analyse page IA'); logAI('✅ Analyse structurée ajoutée'); }, function(error) { logAI('❌ Erreur: ' + error); }); } function aiTypoSuggestion() { const activeCanvas = getActiveCanvas(); if (!activeCanvas) { logAI('❌ Aucun canvas actif'); return; } const prompt = 'Crée 3 exemples de textes avec des pairings de polices pour print. Pour chaque exemple, donne: un titre en gras grande taille, un sous-titre moyen et un corps de texte. Réponds au format JSON:\n{"examples": [{"title": "Titre Exemple", "subtitle": "Sous-titre", "body": "Corps de texte", "titleFont": "Bebas Neue", "bodyFont": "Inter", "titleSize": 36, "subtitleSize": 18, "bodySize": 12}]}'; logAI('📝 Génération d\'exemples typographiques...'); callAI(prompt, function(response) { logAI('📥 Réponse reçue'); try { // Extraire le JSON let jsonMatch = response.match(/\{[\s\S]*"examples"[\s\S]*\}/); if (!jsonMatch) { jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/); if (jsonMatch) jsonMatch = [jsonMatch[1]]; } if (!jsonMatch) throw new Error('Pas de JSON trouvé'); const data = JSON.parse(jsonMatch[0]); if (!data.examples || !Array.isArray(data.examples)) { throw new Error('Format invalide'); } logAI('✅ ' + data.examples.length + ' exemple(s) à créer'); let yPos = 100; data.examples.forEach(function(ex, index) { // Titre const title = new fabric.Textbox(ex.title || 'Titre', { left: 100, top: yPos, width: 400, fontSize: ex.titleSize || 32, fill: '#000', fontFamily: ex.titleFont || 'Bebas Neue', fontWeight: 'bold' }); activeCanvas.add(title); yPos += (ex.titleSize || 32) + 15; // Sous-titre if (ex.subtitle) { const subtitle = new fabric.Textbox(ex.subtitle, { left: 100, top: yPos, width: 400, fontSize: ex.subtitleSize || 18, fill: '#333', fontFamily: ex.bodyFont || 'Inter', fontWeight: '600' }); activeCanvas.add(subtitle); yPos += (ex.subtitleSize || 18) + 10; } // Corps if (ex.body) { const body = new fabric.Textbox(ex.body, { left: 100, top: yPos, width: 400, fontSize: ex.bodySize || 12, fill: '#666', fontFamily: ex.bodyFont || 'Inter' }); activeCanvas.add(body); yPos += (ex.bodySize || 12) * 2 + 30; } logAI(' ✓ Exemple ' + (index + 1) + ': ' + ex.titleFont + ' / ' + ex.bodyFont); }); activeCanvas.renderAll(); saveState('Exemples typo IA ajoutés'); logAI('✨ Exemples typographiques créés!'); } catch (parseError) { logAI('⚠️ Affichage texte:\n' + response); } }, function(error) { logAI('❌ Erreur: ' + error); }); } // Initialize AI provider dropdown on page load if (document.getElementById('aiProvider')) { document.getElementById('aiProvider').addEventListener('change', function() { updateAIModelDropdown(this.value); }); } // ==================== END AI ASSISTANT FUNCTIONS ==================== // Settings modal theme toggle document.addEventListener('click', (e) => { const btn = e.target.closest('#settingsTheme .btn'); if (!btn) return; document.querySelectorAll('#settingsTheme .btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); // Settings modal unit toggle document.addEventListener('click', (e) => { const btn = e.target.closest('#settingsUnits .btn'); if (!btn) return; document.querySelectorAll('#settingsUnits .btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); // Language selector change handler document.addEventListener('change', (e) => { if (e.target.id === 'settingsLang') { const newLang = e.target.value; // Temporairement changer la langue pour mettre à jour les unités const oldLang = currentLanguage; currentLanguage = newLang; updateUnitsForLanguage(); // Activer la première unité par défaut const firstUnit = document.querySelector('#settingsUnits .btn'); if (firstUnit) { document.querySelectorAll('#settingsUnits .btn').forEach(b => b.classList.remove('active')); firstUnit.classList.add('active'); } // Restaurer la langue pour l'interface (sera appliquée au clic sur Appliquer) currentLanguage = oldLang; } }); // ========== SYSTÈME DE TRADUCTIONS ========== const translations = { fr: { // Interface principale format: "Format", margins: "Marges (mm)", tools: "Outils", textBlocks: "Blocs de texte", layers: "Calques", transformation: "Transformation", typography: "Typographie", colors: "Couleurs", alignment: "Alignement", imposition: "Imposition", history: "Historique", settingsTitle: "Réglages généraux", units: "Unités", language: "Langue", theme: "Thème", rememberMe: "Se souvenir de moi (éviter la saisie du mot de passe)", cancel: "Annuler", apply: "Appliquer", settings: "Réglages", toolText: "Texte", toolImage: "Image", toolRect: "Rect", toolCircle: "Rond", toolStar: "Étoile", toolPen: "Plume", toolSVG: "SVG", singlePage: "Pages simples", doublePage: "Double page", rulers: "Règles", guideVertical: "Vertical", guideHorizontal: "Horizontal", clearGuides: "Effacer tous les repères", passwordPlaceholder: "MOT DE PASSE", remember7days: "Garder la session ouverte 7 jours", loginButton: "CONNEXION", loginError: "Mot de passe incorrect", showChemin: "Chemin de fer", cheminTitle: "Chemin de fer - Réorganiser les pages", cheminHint: "Glissez-déposez les pages pour les réorganiser", close: "Fermer", devTools: "Outils Développeur", devToolsTitle: "Outils Développeur - SuperPrint API & Scripts", // Styles widget stylesHeader: "Styles", addStyle: "+ Ajouter votre style…", createStyleTitle: "Créer un style typographique", styleBasics: "Informations de base", styleName: "Nom du style", styleTypography: "Typographie", styleFontFamily: "Famille de police", styleFontSize: "Taille", styleFontWeight: "Graisse", styleLineHeight: "Interlignage", styleLetterSpacing: "Esp. lettres", styleColor: "Couleur", styleAlign: "Alignement", stylePreview: "Aperçu", // Boutons export: "Export", save: "Save", open: "Ouvrir", undo: "↶", redo: "↷", prevPage: "◀ Page", nextPage: "Page ▶", addPage: "Page +", // Unités mm: "mm", cm: "cm", // Formats A4: "A4 (210×297)", A3: "A3 (297×420)", A5: "A5 (148×210)", letter: "Letter (216×279)", legal: "Legal (216×356)", tabloid: "Tabloid (279×432)", // FAQ faqTitle: "Guide utilisateur & FAQ", faqQ1: "Comment exporter mon PDF ?", faqA1: "Utilisez le bouton Exporter PDF dans la barre latérale. Choisissez la qualité, le format, et cliquez sur Exporter. Les repères, fonds perdus et imposition sont gérés automatiquement.", faqQ2: "Comment déplacer les pages dans le chemin de fer ?", faqA2: "Ouvrez le Chemin de fer via le bouton dans la barre latérale. Glissez-déposez les miniatures pour réorganiser, ou déposez dans la zone “fin” pour mettre une page en dernière position.", faqQ3: "Comment mettre du texte en gras, italique, surligné ?", faqA3: "Sélectionnez le texte dans un bloc, puis cliquez sur B, I, U ou le bouton surlignage. Le style s’applique à la sélection ou à tout le bloc si rien n’est sélectionné.", faqQ4: "Comment réinitialiser la typo d’un bloc ?", faqA4: "Cliquez sur le bouton Reset (noir) dans la section typo pour remettre le style par défaut (police, couleur, etc.).", faqQ5: "Comment utiliser les guides et repères ?", faqA5: "Activez/désactivez les repères avec le bouton dans la barre latérale. Les traits verts (coupes), rouges (marges) et bleus (sélection) s’affichent selon le mode page ou double page.", faqQShortcuts: "Raccourcis clavier principaux", faqAShortcuts: "
\n
Ctrl + B : Mettre le texte sélectionné en gras
\n
Ctrl + I : Mettre le texte sélectionné en italique
\n
Ctrl + U : Souligner le texte sélectionné
\n
Ctrl + Z : Annuler la dernière action
\n
Ctrl + Y : Rétablir l’action annulée
\n
Suppr : Supprimer le bloc ou la page sélectionné(e)
\n
Flèches : Déplacer le bloc sélectionné
\n
Ctrl + C : Copier le bloc sélectionné
\n
Ctrl + V : Coller le bloc copié
\n
Ctrl + A : Tout sélectionner dans le bloc
\n
Ctrl + Shift + H : Surligner la sélection
\n
Ctrl + Shift + G : Grouper les blocs sélectionnés
\n
Ctrl + Shift + U : Dégrouper
\n
Ctrl + S : Sauvegarder le projet
\n
Ctrl + E : Ouvrir la fenêtre d’export PDF
\n
Ctrl + Shift + F : Afficher/masquer les repères
\n
Ctrl + Shift + R : Réinitialiser la typo du bloc
\n
Alt + Glisser : Dupliquer la sélection et déplacer la copie
\n
\n Certains raccourcis peuvent varier selon le système ou le navigateur." }, en: { // Interface principale format: "Format", margins: "Margins (in)", tools: "Tools", textBlocks: "Text Blocks", layers: "Layers", transformation: "Transformation", typography: "Typography", colors: "Colors", alignment: "Alignment", imposition: "Imposition", history: "History", settingsTitle: "General Settings", units: "Units", language: "Language", theme: "Theme", rememberMe: "Remember me (skip password entry)", cancel: "Cancel", apply: "Apply", settings: "Settings", toolText: "Text", toolImage: "Image", toolRect: "Rect", toolCircle: "Circle", toolStar: "Star", toolPen: "Pen", toolSVG: "SVG", singlePage: "Single pages", doublePage: "Spread", rulers: "Rulers", guideVertical: "Vertical", guideHorizontal: "Horizontal", clearGuides: "Clear all guides", passwordPlaceholder: "PASSWORD", remember7days: "Keep session open 7 days", loginButton: "LOGIN", loginError: "Incorrect password", showChemin: "Flatplan", cheminTitle: "Flatplan - Reorder pages", cheminHint: "Drag and drop pages to reorder", close: "Close", devTools: "Developer Tools", devToolsTitle: "Developer Tools - SuperPrint API & Scripts", // Styles widget stylesHeader: "Styles", addStyle: "+ Add your style…", createStyleTitle: "Create a typography style", styleBasics: "Basics", styleName: "Style name", styleTypography: "Typography", styleFontFamily: "Font family", styleFontSize: "Font size", styleFontWeight: "Font weight", styleLineHeight: "Line height", styleLetterSpacing: "Letter spacing", styleColor: "Color", styleAlign: "Align", stylePreview: "Preview", // Boutons export: "Export", save: "Save", open: "Open", undo: "↶", redo: "↷", prevPage: "◀ Page", nextPage: "Page ▶", addPage: "Page +", // Unités mm: "mm", cm: "cm", in: "in", // Formats A4: "A4 (210×297)", A3: "A3 (297×420)", A5: "A5 (148×210)", letter: "Letter (216×279)", legal: "Legal (216×356)", tabloid: "Tabloid (279×432)", // FAQ faqTitle: "User Guide & FAQ", faqQ1: "How do I export my PDF?", faqA1: "Use the Export PDF button in the sidebar. Pick quality and format, then click Export. Crop marks, bleed and imposition are handled automatically.", faqQ2: "How do I reorder pages in the Flatplan?", faqA2: "Open the Flatplan from the sidebar button. Drag thumbnails to reorder, or drop into the “end” zone to send a page to the last position.", faqQ3: "How do I make text bold, italic or highlighted?", faqA3: "Select text in a block, then click B, I, U or the highlight button. The style applies to the selection or the whole block if nothing is selected.", faqQ4: "How do I reset a block’s typography?", faqA4: "Click the Reset (black) button in the typography section to restore default style (font, color, etc.).", faqQ5: "How do I use rulers and guides?", faqA5: "Toggle guides with the sidebar button. Green (crop), red (margins) and blue (selection) lines display depending on single or spread mode.", faqQShortcuts: "Essential keyboard shortcuts", faqAShortcuts: "
\n
Ctrl + B: Make selection bold
\n
Ctrl + I: Make selection italic
\n
Ctrl + U: Underline selection
\n
Ctrl + Z: Undo
\n
Ctrl + Y: Redo
\n
Delete: Delete selected block or page
\n
Arrow keys: Move selected block
\n
Ctrl + C: Copy
\n
Ctrl + V: Paste
\n
Ctrl + A: Select all in block
\n
Ctrl + Shift + H: Highlight selection
\n
Ctrl + Shift + G: Group selection
\n
Ctrl + Shift + U: Ungroup
\n
Ctrl + S: Save project
\n
Ctrl + E: Open Export PDF
\n
Ctrl + Shift + F: Show/hide guides
\n
Ctrl + Shift + R: Reset block typography
\n
Alt + Drag: Duplicate selection and move copy
\n
\n Some shortcuts may vary depending on OS or browser." }, ja: { // Interface principale format: "フォーマット", margins: "余白 (mm)", tools: "ツール", textBlocks: "テキストブロック", layers: "レイヤー", transformation: "変形", typography: "タイポグラフィー", colors: "カラー", alignment: "整列", imposition: "面付け", history: "履歴", // Outils toolText: "テキスト", toolImage: "画像", toolRect: "長方形", toolCircle: "円", toolStar: "星", toolPen: "ペン", toolSVG: "SVG", showChemin: "フラットプラン", cheminTitle: "フラットプラン - ページを並べ替え", cheminHint: "ページをドラッグ&ドロップして並べ替え", close: "閉じる", devTools: "開発者ツール", devToolsTitle: "開発者ツール - SuperPrint API とスクリプト", // Styles widget stylesHeader: "スタイル", addStyle: "+ スタイルを追加…", createStyleTitle: "タイポスタイルを作成", styleBasics: "基本情報", styleName: "スタイル名", styleTypography: "タイポグラフィ", styleFontFamily: "フォントファミリー", styleFontSize: "サイズ", styleFontWeight: "ウェイト", styleLineHeight: "行送り", styleLetterSpacing: "文字間", styleColor: "色", styleAlign: "配置", stylePreview: "プレビュー", // Boutons export: "エクスポート", save: "保存", open: "開く", undo: "↶", redo: "↷", prevPage: "◀ ページ", nextPage: "ページ ▶", addPage: "ページ +", // Unités mm: "mm", cm: "cm", // Formats A4: "A4 (210×297)", A3: "A3 (297×420)", A5: "A5 (148×210)", letter: "レター (216×279)", legal: "リーガル (216×356)", tabloid: "タブロイド (279×432)", // FAQ faqTitle: "ユーザーガイド & FAQ", faqQ1: "PDF をエクスポートするには?", faqA1: "サイドバーの PDF 書き出し を使います。品質とフォーマットを選び、書き出し をクリック。トンボ、塗り足し、面付は自動処理されます。", faqQ2: "フラットプランでページを入れ替えるには?", faqA2: "サイドバーの フラットプラン を開き、サムネイルをドラッグして並べ替えます。『最後』ゾーンにドロップすると末尾に移動します。", faqQ3: "太字・斜体・ハイライトは?", faqA3: "テキストを選択して B/I/U またはハイライトをクリック。選択範囲がなければブロック全体に適用されます。", faqQ4: "ブロックのタイポをリセットするには?", faqA4: "タイポグラフィーの Reset を押すと既定のスタイルに戻します。", faqQ5: "ルーラーとガイドの使い方は?", faqA5: "サイドバーのボタンでガイドを切り替えます。緑(トンボ)/赤(マージン)/青(選択)が、単一ページ/見開きに応じて表示されます。", faqQShortcuts: "主要ショートカット", faqAShortcuts: "
\n
Ctrl + B: 太字
\n
Ctrl + I: 斜体
\n
Ctrl + U: 下線
\n
Ctrl + Z: 元に戻す
\n
Ctrl + Y: やり直し
\n
Delete: 選択ブロック/ページを削除
\n
矢印キー: ブロック移動
\n
Ctrl + C: コピー
\n
Ctrl + V: 貼り付け
\n
Ctrl + A: ブロック内すべて選択
\n
Ctrl + Shift + H: ハイライト
\n
Ctrl + Shift + G: グループ化
\n
Ctrl + Shift + U: グループ解除
\n
Ctrl + S: プロジェクト保存
\n
Ctrl + E: PDF 書き出しを開く
\n
Ctrl + Shift + F: ガイド表示/非表示
\n
Ctrl + Shift + R: ブロックのタイポをリセット
\n
Alt + Drag: 複製して移動
\n
\n ショートカットは環境により異なる場合があります。" } }; // Configuration des unités par langue const unitsPerLanguage = { fr: ['mm', 'cm'], en: ['in', 'mm'], ja: ['mm', 'cm'] }; let currentLanguage = 'fr'; function translate(key) { return translations[currentLanguage]?.[key] || translations.fr[key] || key; } function updateInterface() { // Mettre à jour tous les éléments avec data-translate document.querySelectorAll('[data-translate]').forEach(element => { const key = element.getAttribute('data-translate'); element.textContent = translate(key); }); // Mettre à jour les éléments qui contiennent du HTML traduit document.querySelectorAll('[data-translate-html]').forEach(element => { const key = element.getAttribute('data-translate-html'); const val = translate(key); // Si aucune traduction spécifique, conserver le contenu existant if (val) element.innerHTML = val; }); // Mettre à jour le branding pour le japonais document.querySelectorAll('[data-brand]').forEach(el => { if (currentLanguage === 'ja') { el.textContent = 'スーパー・プリント'; } else { el.textContent = 'SUPER PRINT'; } }); // Mettre à jour les unités selon la langue updateUnitsForLanguage(); // Mettre à jour les textes du widget de styles renderTypographyStylesList(); } function updateUnitsForLanguage() { const units = unitsPerLanguage[currentLanguage] || ['mm', 'cm']; const unitToggle = document.querySelector('.unit-toggle'); if (unitToggle) { unitToggle.innerHTML = ''; units.forEach((unit, index) => { const btn = document.createElement('div'); btn.className = `unit-btn ${index === 0 ? 'active' : ''}`; btn.dataset.unit = unit; btn.textContent = unit; // Localized titles for units if (currentLanguage === 'ja') { btn.title = unit === 'mm' ? 'ミリメートル' : unit === 'cm' ? 'センチメートル' : 'インチ'; } else if (currentLanguage === 'en') { btn.title = unit === 'mm' ? 'Millimeters' : unit === 'cm' ? 'Centimeters' : 'Inches'; } else { btn.title = unit === 'mm' ? 'Millimètres' : unit === 'cm' ? 'Centimètres' : 'Pouces'; } unitToggle.appendChild(btn); // Ajouter l'event listener btn.addEventListener('click', () => { document.querySelectorAll('.unit-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); if (typeof currentUnit !== 'undefined') { currentUnit = unit; // Adapter steps des inputs de transformation selon l'unité const step = currentUnit === 'cm' ? 0.01 : (currentUnit === 'in' ? 0.01 : 0.1); ['transformX','transformY','transformW','transformH'].forEach(id => { const el = document.getElementById(id); if (el) el.step = step; }); } }); }); // Mettre currentUnit par défaut selon la langue (première unité) if (typeof currentUnit !== 'undefined') { currentUnit = units[0]; // Adapter steps au défaut const step = currentUnit === 'cm' ? 0.01 : (currentUnit === 'in' ? 0.01 : 0.1); ['transformX','transformY','transformW','transformH'].forEach(id => { const el = document.getElementById(id); if (el) el.step = step; }); } } // Mettre à jour aussi les unités dans les réglages const settingsUnits = document.getElementById('settingsUnits'); if (settingsUnits) { settingsUnits.innerHTML = ''; units.forEach((unit, index) => { const btn = document.createElement('button'); btn.className = `btn btn-secondary ${index === 0 ? 'active' : ''}`; btn.dataset.unit = unit; if (currentLanguage === 'en') { btn.textContent = unit === 'in' ? 'Inches (in)' : unit === 'mm' ? 'Millimeters (mm)' : 'Centimeters (cm)'; } else if (currentLanguage === 'ja') { btn.textContent = unit === 'mm' ? 'ミリメートル (mm)' : 'センチメートル (cm)'; } else { // FR ou autres btn.textContent = unit === 'mm' ? 'Millimètres (mm)' : 'Centimètres (cm)'; } settingsUnits.appendChild(btn); }); } } function setLanguage(lang) { currentLanguage = lang; localStorage.setItem('sp_lang', lang); document.documentElement.lang = lang; updateInterface(); // Adapter unités et suffixes pour les labels de dimensions de page const wLabel = document.getElementById('labelPageW'); const hLabel = document.getElementById('labelPageH'); if (wLabel && hLabel) { const suffix = (currentLanguage === 'en') ? 'in' : 'mm'; if (currentLanguage === 'fr') { wLabel.textContent = `L (${suffix})`; hLabel.textContent = `H (${suffix})`; } else if (currentLanguage === 'ja') { wLabel.textContent = `幅 (${suffix})`; hLabel.textContent = `高さ (${suffix})`; } else { wLabel.textContent = `W (${suffix})`; hLabel.textContent = `H (${suffix})`; } } // Conversion des valeurs de page et marges si langue EN => inches, sinon mm try { if (currentLanguage === 'en') { // Convertir mm -> in pour affichage document.getElementById('pageWidth').value = mmToIn(pageFormat.width).toFixed(2); document.getElementById('pageHeight').value = mmToIn(pageFormat.height).toFixed(2); document.getElementById('margin').value = mmToIn(margin).toFixed(2); document.getElementById('bleed').value = mmToIn(bleed).toFixed(2); } else { // Afficher mm document.getElementById('pageWidth').value = pageFormat.width; document.getElementById('pageHeight').value = pageFormat.height; document.getElementById('margin').value = margin; document.getElementById('bleed').value = bleed; } } catch {} } // ========== TYPOGRAPHY STYLES (Widget + Modal) ========== let typographyStyles = []; function defaultTypographyStyles() { return [ { id: 'title', name: 'Titre', fontFamily: 'Inter', fontSize: 28, fontWeight: '700', lineHeight: 1.15, charSpacing: 0, fill: '#000000', textAlign: 'left' }, { id: 'subtitle', name: 'Sous-titre', fontFamily: 'Inter', fontSize: 18, fontWeight: '600', lineHeight: 1.2, charSpacing: 0, fill: '#111111', textAlign: 'left' }, { id: 'body', name: 'Corps', fontFamily: 'Inter', fontSize: 12, fontWeight: '400', lineHeight: 1.4, charSpacing: 0, fill: '#222222', textAlign: 'left' }, { id: 'caption', name: 'Légende', fontFamily: 'Inter', fontSize: 10, fontWeight: '400', lineHeight: 1.2, charSpacing: 50, fill: '#333333', textAlign: 'left' } ]; } function loadTypographyStyles() { try { const raw = localStorage.getItem('sp_typo_styles'); typographyStyles = raw ? JSON.parse(raw) : defaultTypographyStyles(); } catch { typographyStyles = defaultTypographyStyles(); } } function saveTypographyStyles() { try { localStorage.setItem('sp_typo_styles', JSON.stringify(typographyStyles)); } catch {} } function renderTypographyStylesList() { const list = document.getElementById('typoStylesList'); if (!list) return; // Preserve the add button const addBtn = document.getElementById('typoStyleAddBtn'); list.innerHTML = ''; typographyStyles.forEach((st, idx) => { const item = document.createElement('div'); item.className = 'typo-style-item'; item.dataset.index = String(idx); item.innerHTML = ` ${st.name}
${st.fontFamily || ''} • ${st.fontSize || ''}
`; // Apply on click on main label area only item.addEventListener('click', (e) => { const actionBtn = e.target.closest('button'); if (actionBtn) return; // handled separately e.stopPropagation(); applyTypographyStyle(st); }); // Edit / Delete handlers item.querySelector('[data-action="edit"]').addEventListener('click', (e) => { e.stopPropagation(); openEditStyleModal(idx); }); item.querySelector('[data-action="delete"]').addEventListener('click', (e) => { e.stopPropagation(); deleteTypographyStyle(idx); }); list.appendChild(item); }); if (addBtn) list.appendChild(addBtn); // Update i18n header/labels const hdr = document.querySelector('#typoStylesWidget [data-translate="stylesHeader"]'); if (hdr) hdr.textContent = translate('stylesHeader'); const addLbl = document.getElementById('typoStyleAddBtn'); if (addLbl) addLbl.textContent = translate('addStyle'); } // Fonction pour amener un widget au premier plan let widgetZIndexCounter = 100; function bringWidgetToFront(widgetElement) { widgetZIndexCounter++; widgetElement.style.zIndex = widgetZIndexCounter; } function applyTypographyStyle(style) { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const target = activeCanvas.getActiveObject(); const applyToText = (obj) => { if (obj && obj.type === 'textbox') { obj.set({ fontFamily: style.fontFamily || obj.fontFamily, fontSize: style.fontSize || obj.fontSize, fontWeight: style.fontWeight || obj.fontWeight, lineHeight: style.lineHeight || obj.lineHeight, charSpacing: (typeof style.charSpacing === 'number') ? style.charSpacing : obj.charSpacing, fill: style.fill || obj.fill, textAlign: style.textAlign || obj.textAlign }); obj.setCoords(); } }; if (target && target.type === 'activeSelection') { target._objects.forEach(applyToText); } else { applyToText(target); } activeCanvas.discardActiveObject(); activeCanvas.renderAll(); saveState('Style typographique appliqué'); } function openCreateStyleModal() { const ov = document.getElementById('createStyleModal'); if (ov) { // Populate font families dropdown try { const select = document.getElementById('styleFontFamily'); if (select) { select.innerHTML = ''; const fonts = getAllAvailableFontFamilies(); fonts.forEach(f => { const opt = document.createElement('option'); opt.value = f; opt.textContent = f + (isCustomFont(f) ? ' (Custom)' : ''); select.appendChild(opt); }); // Hook change to update weight list select.addEventListener('change', updateStyleWeightsOptions); updateStyleWeightsOptions(); // Hook preview listeners const previewInputs = ['styleFontFamily','styleFontSize','styleFontWeight','styleLineHeight','styleLetterSpacing','styleColor','styleAlign']; previewInputs.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('input', updateStylePreviewBox); if (el) el.addEventListener('change', updateStylePreviewBox); }); updateStylePreviewBox(); } } catch {} ov.classList.add('active'); } } function closeCreateStyleModal() { const ov = document.getElementById('createStyleModal'); if (ov) ov.classList.remove('active'); } function saveNewTypographyStyle() { const name = (document.getElementById('styleName')?.value || '').trim(); if (!name) { closeCreateStyleModal(); return; } const fontFamily = (document.getElementById('styleFontFamily')?.value || 'Inter').trim(); const fontSize = parseFloat(document.getElementById('styleFontSize')?.value || '14'); const fontWeight = (document.getElementById('styleFontWeight')?.value || '400'); const lineHeight = parseFloat(document.getElementById('styleLineHeight')?.value || '1.2'); const charSpacing = parseInt(document.getElementById('styleLetterSpacing')?.value || '0', 10); const fill = document.getElementById('styleColor')?.value || '#000000'; const textAlign = document.getElementById('styleAlign')?.value || 'left'; const id = `${name.toLowerCase().replace(/\s+/g,'-')}-${Date.now()}`; const style = { id, name, fontFamily, fontSize, fontWeight, lineHeight, charSpacing, fill, textAlign }; typographyStyles.push(style); saveTypographyStyles(); renderTypographyStylesList(); closeCreateStyleModal(); } function openEditStyleModal(index) { const st = typographyStyles[index]; if (!st) return; openCreateStyleModal(); // Prefill const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; setVal('styleName', st.name); setVal('styleFontFamily', st.fontFamily || 'Inter'); updateStyleWeightsOptions(); setVal('styleFontWeight', st.fontWeight || '400'); setVal('styleFontSize', st.fontSize || 14); setVal('styleLineHeight', st.lineHeight || 1.2); setVal('styleLetterSpacing', st.charSpacing || 0); setVal('styleColor', st.fill || '#000000'); setVal('styleAlign', st.textAlign || 'left'); updateStylePreviewBox(); // Overwrite save to update existing const saveBtn = document.querySelector('#createStyleModal .modal-footer .btn:not(.btn-secondary)'); if (saveBtn) { const originalHandler = saveNewTypographyStyle; saveBtn.onclick = () => { const name = (document.getElementById('styleName')?.value || '').trim(); if (!name) { closeCreateStyleModal(); return; } const style = { ...st, name, fontFamily: (document.getElementById('styleFontFamily')?.value || 'Inter').trim(), fontSize: parseFloat(document.getElementById('styleFontSize')?.value || '14'), fontWeight: (document.getElementById('styleFontWeight')?.value || '400'), lineHeight: parseFloat(document.getElementById('styleLineHeight')?.value || '1.2'), charSpacing: parseInt(document.getElementById('styleLetterSpacing')?.value || '0', 10), fill: document.getElementById('styleColor')?.value || '#000000', textAlign: document.getElementById('styleAlign')?.value || 'left' }; typographyStyles[index] = style; saveTypographyStyles(); renderTypographyStylesList(); closeCreateStyleModal(); }; } } function deleteTypographyStyle(index) { const st = typographyStyles[index]; if (!st) return; const msg = translate('confirmDeleteStyle') || 'Delete this style?'; if (!confirm(msg + `\n\n${st.name}`)) return; typographyStyles.splice(index,1); saveTypographyStyles(); renderTypographyStylesList(); } function updateStylePreviewBox() { const box = document.getElementById('stylePreviewBox'); if (!box) return; const fam = document.getElementById('styleFontFamily')?.value || 'Inter'; const size = parseFloat(document.getElementById('styleFontSize')?.value || '14'); const weight = document.getElementById('styleFontWeight')?.value || '400'; const lh = parseFloat(document.getElementById('styleLineHeight')?.value || '1.2'); const cs = parseInt(document.getElementById('styleLetterSpacing')?.value || '0', 10); const color = document.getElementById('styleColor')?.value || '#000000'; const align = document.getElementById('styleAlign')?.value || 'left'; box.style.fontFamily = fam; box.style.fontSize = `${size}px`; box.style.fontWeight = weight; box.style.lineHeight = String(lh); box.style.letterSpacing = `${cs/1000}em`; box.style.color = color; box.style.textAlign = align === 'justify' ? 'justify' : align; } function setupDraggableStylesWidget() { const widget = document.getElementById('typoStylesWidget'); const header = document.getElementById('typoStylesToggle'); const container = document.getElementById('canvasScrollArea'); if (!widget || !header || !container) return; // Restore saved position (ou positionner en bas à gauche par défaut) setTimeout(() => { try { const saved = JSON.parse(localStorage.getItem('sp_styles_widget_pos') || 'null'); if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') { widget.style.left = saved.x + 'px'; widget.style.top = saved.y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } else { // Position par défaut : en bas à gauche (troisième position) widget.style.left = '16px'; widget.style.top = 'auto'; widget.style.bottom = '144px'; widget.style.right = 'auto'; } } catch {} }, 100); let isDown = false; let startX = 0, startY = 0, originLeft = 0, originTop = 0; header.addEventListener('mousedown', (e) => { isDown = true; widget.dataset.dragging = '0'; const rect = widget.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; originLeft = rect.left - containerRect.left + container.scrollLeft; originTop = rect.top - containerRect.top + container.scrollTop; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // Threshold to consider it a drag if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (widget.dataset.dragging !== '1') widget.dataset.dragging = '1'; let x = originLeft + dx; let y = originTop + dy; // Constrain within container bounds const contRect = container.getBoundingClientRect(); const wRect = widget.getBoundingClientRect(); const maxX = contRect.width - wRect.width; const maxY = contRect.height - wRect.height; x = Math.max(0, Math.min(x, maxX + container.scrollLeft)); y = Math.max(0, Math.min(y, maxY + container.scrollTop)); widget.style.left = x + 'px'; widget.style.top = y + 'px'; widget.style.bottom = 'auto'; } }); document.addEventListener('mouseup', () => { if (!isDown) return; isDown = false; const wasDragging = widget.dataset.dragging === '1'; setTimeout(() => { widget.dataset.dragging = '0'; }, 50); document.body.style.userSelect = ''; // Save position if (wasDragging) { const left = parseInt(widget.style.left || '16', 10) || 0; const top = parseInt(widget.style.top || '16', 10) || 0; try { localStorage.setItem('sp_styles_widget_pos', JSON.stringify({ x: left, y: top })); } catch {} } }); } // 🎯 NOUVELLE FONCTION: Repositionner le widget dans la zone visible function ensureWidgetVisible() { const widget = document.getElementById('typoStylesWidget'); const container = document.getElementById('canvasScrollArea'); if (!widget || !container) return; const containerRect = container.getBoundingClientRect(); const widgetRect = widget.getBoundingClientRect(); // Vérifier si le widget est en dehors de la zone visible const isAboveViewport = widgetRect.bottom < containerRect.top; const isBelowViewport = widgetRect.top > containerRect.bottom; const isLeftOfViewport = widgetRect.right < containerRect.left; const isRightOfViewport = widgetRect.left > containerRect.right; // Si le widget n'est pas visible, le repositionner if (isAboveViewport || isBelowViewport || isLeftOfViewport || isRightOfViewport) { // Position par défaut: en bas à gauche de la zone visible const scrollTop = container.scrollTop; const scrollLeft = container.scrollLeft; const viewportHeight = containerRect.height; // Placer le widget en bas à gauche de la zone visible const newLeft = scrollLeft + 16; const newTop = scrollTop + viewportHeight - widgetRect.height - 16; widget.style.left = newLeft + 'px'; widget.style.top = newTop + 'px'; widget.style.bottom = 'auto'; // Sauvegarder la nouvelle position try { localStorage.setItem('sp_styles_widget_pos', JSON.stringify({ x: newLeft, y: newTop })); } catch {} } } // ===== Font helpers for style modal ===== function getAllAvailableFontFamilies() { // From preloaded known list + any customFonts added const base = ['Inter','Poppins','Playfair Display','Bebas Neue','IBM Plex Mono','JetBrains Mono','Fira Code','Space Mono']; const customs = (customFonts || []).map(f => f.name); // De-duplicate while preserving order (base first, then customs) const seen = new Set(); return [...base, ...customs].filter(f => (seen.has(f) ? false : (seen.add(f), true))); } function isCustomFont(name) { return (customFonts || []).some(f => f.name === name); } function getWeightsForFont(name) { return fontWeights[name] || ['400']; } function updateStyleWeightsOptions() { const family = document.getElementById('styleFontFamily')?.value; const weightSelect = document.getElementById('styleFontWeight'); if (!family || !weightSelect) return; const weights = getWeightsForFont(family); weightSelect.innerHTML = ''; weights.forEach(w => { const opt = document.createElement('option'); opt.value = w; opt.textContent = w; if (w === '400') opt.selected = true; weightSelect.appendChild(opt); }); } // ==================== 🎨 COLOR SWATCHES WIDGET ==================== let colorSwatches = []; // Fonction de conversion RGB vers CMYK (pour l'affichage print professionnel) function rgbToCMYK(r, g, b) { // Normaliser RGB (0-255 vers 0-1) let rNorm = r / 255; let gNorm = g / 255; let bNorm = b / 255; // Calculer K (noir) let k = 1 - Math.max(rNorm, gNorm, bNorm); // Éviter division par zéro if (k === 1) { return { c: 0, m: 0, y: 0, k: 100 }; } // Calculer CMY let c = (1 - rNorm - k) / (1 - k); let m = (1 - gNorm - k) / (1 - k); let y = (1 - bNorm - k) / (1 - k); // Convertir en pourcentages (0-100) return { c: Math.round(c * 100), m: Math.round(m * 100), y: Math.round(y * 100), k: Math.round(k * 100) }; } // Extraire RGB depuis différents formats de couleur function extractRGB(color) { if (!color) return null; // Format hex (#RRGGBB ou #RGB) if (color.startsWith('#')) { const hex = color.slice(1); let r, g, b; if (hex.length === 3) { r = parseInt(hex[0] + hex[0], 16); g = parseInt(hex[1] + hex[1], 16); b = parseInt(hex[2] + hex[2], 16); } else if (hex.length === 6) { r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16); } return { r, g, b }; } // Format rgba(r, g, b, a) const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgbaMatch) { return { r: parseInt(rgbaMatch[1]), g: parseInt(rgbaMatch[2]), b: parseInt(rgbaMatch[3]) }; } // Pour les dégradés, extraire la première couleur if (color.includes('gradient')) { const hexMatch = color.match(/#[0-9A-Fa-f]{6}/); if (hexMatch) { return extractRGB(hexMatch[0]); } } return null; } // Formater CMYK pour l'affichage function formatCMYK(color) { const rgb = extractRGB(color); if (!rgb) return null; const cmyk = rgbToCMYK(rgb.r, rgb.g, rgb.b); return `C${cmyk.c} M${cmyk.m} Y${cmyk.y} K${cmyk.k}`; } // Couleurs pré-enregistrées par défaut (palette harmonieuse) const defaultSwatches = [ { name: 'Noir pur', color: '#000000', type: 'solid' }, { name: 'Blanc', color: '#FFFFFF', type: 'solid' }, { name: 'Gris ardoise', color: '#64748B', type: 'solid' }, { name: 'Bleu électrique', color: '#0EA5E9', type: 'solid' }, { name: 'Rose vif', color: '#EC4899', type: 'solid' }, { name: 'Violet profond', color: '#8B5CF6', type: 'solid' }, { name: 'Orange sunset', color: '#F97316', type: 'solid' }, { name: 'Vert émeraude', color: '#10B981', type: 'solid' }, { name: 'Dégradé sunrise', color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', type: 'gradient' }, { name: 'Dégradé ocean', color: 'linear-gradient(135deg, #2E3192 0%, #1BFFFF 100%)', type: 'gradient' }, { name: 'Dégradé fire', color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', type: 'gradient' }, { name: 'Dégradé forest', color: 'linear-gradient(135deg, #0BA360 0%, #3CBA92 100%)', type: 'gradient' } ]; function setupSwatchesWidget() { const widget = document.getElementById('swatchesWidget'); const header = document.getElementById('swatchesToggle'); const list = document.getElementById('swatchesList'); const addBtn = document.getElementById('swatchAddBtn'); if (!widget || !header || !list || !addBtn) return; // Charger les swatches depuis localStorage ou utiliser les défauts loadSwatches(); renderSwatches(); // Toggle expand/collapse header.addEventListener('click', (e) => { if (widget.dataset.dragging === '1') return; widget.classList.toggle('expanded'); // Amener le widget au premier plan bringWidgetToFront(widget); const arrow = header.querySelector('.swatches-toggle-arrow'); if (arrow) arrow.textContent = widget.classList.contains('expanded') ? '▴' : '▾'; }); // Bouton ajouter couleur addBtn.addEventListener('click', () => { openAddSwatchModal(); }); // Setup draggable setupDraggableSwatchesWidget(); } function setupDraggableSwatchesWidget() { const widget = document.getElementById('swatchesWidget'); const header = document.getElementById('swatchesToggle'); const container = document.getElementById('canvasScrollArea'); if (!widget || !header || !container) return; // Restore saved position (ou positionner en bas à gauche par défaut) setTimeout(() => { try { const saved = JSON.parse(localStorage.getItem('sp_swatches_widget_pos') || 'null'); if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') { widget.style.left = saved.x + 'px'; widget.style.top = saved.y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } else { // Position par défaut : en bas à gauche (deuxième position) widget.style.left = '16px'; widget.style.top = 'auto'; widget.style.bottom = '80px'; widget.style.right = 'auto'; } } catch {} }, 100); let isDown = false; let startX = 0, startY = 0, originLeft = 0, originTop = 0; header.addEventListener('mousedown', (e) => { isDown = true; widget.dataset.dragging = '0'; const rect = widget.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; originLeft = rect.left - containerRect.left + container.scrollLeft; originTop = rect.top - containerRect.top + container.scrollTop; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (widget.dataset.dragging !== '1') widget.dataset.dragging = '1'; let x = originLeft + dx; let y = originTop + dy; const contRect = container.getBoundingClientRect(); const wRect = widget.getBoundingClientRect(); const maxX = contRect.width - wRect.width; const maxY = contRect.height - wRect.height; x = Math.max(0, Math.min(x, maxX + container.scrollLeft)); y = Math.max(0, Math.min(y, maxY + container.scrollTop)); widget.style.left = x + 'px'; widget.style.top = y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } }); document.addEventListener('mouseup', () => { if (!isDown) return; isDown = false; const wasDragging = widget.dataset.dragging === '1'; setTimeout(() => { widget.dataset.dragging = '0'; }, 50); document.body.style.userSelect = ''; if (wasDragging) { const left = parseInt(widget.style.left || '16', 10) || 0; const top = parseInt(widget.style.top || '16', 10) || 0; try { localStorage.setItem('sp_swatches_widget_pos', JSON.stringify({ x: left, y: top })); } catch {} } }); } function loadSwatches() { try { const saved = localStorage.getItem('sp_color_swatches'); if (saved) { colorSwatches = JSON.parse(saved); } else { colorSwatches = [...defaultSwatches]; saveSwatches(); } } catch { colorSwatches = [...defaultSwatches]; } } function saveSwatches() { try { localStorage.setItem('sp_color_swatches', JSON.stringify(colorSwatches)); } catch (e) { console.error('Erreur sauvegarde swatches:', e); } } function renderSwatches() { const list = document.getElementById('swatchesList'); if (!list) return; const addBtn = document.getElementById('swatchAddBtn'); list.innerHTML = ''; // Ajouter le bouton en premier if (addBtn) list.appendChild(addBtn); colorSwatches.forEach((swatch, index) => { const item = document.createElement('div'); item.className = 'swatch-item'; item.title = 'Clic: appliquer | Clic droit: supprimer'; const preview = document.createElement('div'); preview.className = 'swatch-preview'; preview.style.background = swatch.color; const info = document.createElement('div'); info.className = 'swatch-info'; const nameEl = document.createElement('div'); nameEl.className = 'swatch-name'; nameEl.textContent = swatch.name; const valueEl = document.createElement('div'); valueEl.className = 'swatch-value'; if (swatch.type === 'gradient') { valueEl.textContent = 'Dégradé'; } else { // Afficher RGB + CMYK pour les couleurs unies const hexValue = swatch.color.toUpperCase(); valueEl.textContent = hexValue; } // Ajouter valeur CMYK (seulement pour couleurs unies) if (swatch.type === 'solid') { const cmykValue = formatCMYK(swatch.color); if (cmykValue) { const cmykEl = document.createElement('div'); cmykEl.className = 'swatch-cmyk'; cmykEl.textContent = cmykValue; cmykEl.style.fontSize = '8px'; cmykEl.style.color = '#999'; cmykEl.style.marginTop = '2px'; cmykEl.style.fontFamily = 'monospace'; info.appendChild(nameEl); info.appendChild(valueEl); info.appendChild(cmykEl); } else { info.appendChild(nameEl); info.appendChild(valueEl); } } else { info.appendChild(nameEl); info.appendChild(valueEl); } item.appendChild(preview); item.appendChild(info); // Clic: appliquer la couleur item.addEventListener('click', () => { applySwatchToSelection(swatch); }); // Clic droit: supprimer item.addEventListener('contextmenu', (e) => { e.preventDefault(); if (confirm(`Supprimer "${swatch.name}" ?`)) { colorSwatches.splice(index, 1); saveSwatches(); renderSwatches(); } }); list.appendChild(item); }); } function applySwatchToSelection(swatch) { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const selected = activeCanvas.getActiveObject(); if (!selected) { alert('Sélectionnez un objet pour appliquer la couleur.'); return; } if (swatch.type === 'gradient') { // Pour les dégradés, on ne peut appliquer que le fill (pas encore le stroke) if (selected.type === 'textbox' || selected.type === 'i-text' || selected.type === 'text') { // Pour le texte, on prend la première couleur du dégradé const match = swatch.color.match(/#[0-9A-Fa-f]{6}/); if (match) { selected.set('fill', match[0]); document.getElementById('textFill').value = match[0]; } } else { alert('Les dégradés ne sont pas encore supportés pour les formes.\nUtilisez une couleur unie.'); return; } } else { // Couleur unie if (selected.type === 'textbox' || selected.type === 'i-text' || selected.type === 'text') { selected.set('fill', swatch.color); document.getElementById('textFill').value = swatch.color; } else { selected.set('fill', swatch.color); document.getElementById('blockFill').value = swatch.color; } } activeCanvas.renderAll(); saveState(`Couleur appliquée: ${swatch.name}`); } function openAddSwatchModal() { const modal = document.getElementById('swatchCreatorModal'); if (!modal) return; // Reset form document.getElementById('swatchNameInput').value = ''; document.getElementById('solidColorPicker').value = '#000000'; document.getElementById('solidColorHex').value = '#000000'; document.getElementById('solidOpacitySlider').value = '100'; document.getElementById('solidOpacityValue').textContent = '100%'; // Activer le type "solid" par défaut document.querySelectorAll('.swatch-type-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.type === 'solid'); btn.style.borderColor = btn.dataset.type === 'solid' ? '#000' : '#ddd'; }); document.getElementById('solidColorSection').style.display = 'block'; document.getElementById('gradientSection').style.display = 'none'; updateSwatchPreview(); modal.style.display = 'flex'; } function closeSwatchModal() { const modal = document.getElementById('swatchCreatorModal'); if (modal) modal.style.display = 'none'; } function updateSwatchPreview() { const preview = document.getElementById('swatchPreview'); const cmykPreview = document.getElementById('swatchCMYKPreview'); const cmykValues = document.getElementById('cmykValues'); if (!preview) return; const activeType = document.querySelector('.swatch-type-btn.active')?.dataset.type || 'solid'; if (activeType === 'solid') { const color = document.getElementById('solidColorHex').value || '#000000'; const opacity = parseInt(document.getElementById('solidOpacitySlider').value) / 100; // Convertir hex en rgba let r, g, b; if (color.startsWith('#')) { const hex = color.slice(1); if (hex.length === 3) { r = parseInt(hex[0] + hex[0], 16); g = parseInt(hex[1] + hex[1], 16); b = parseInt(hex[2] + hex[2], 16); } else if (hex.length === 6) { r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16); } } preview.style.background = `rgba(${r || 0}, ${g || 0}, ${b || 0}, ${opacity})`; // Afficher CMYK if (cmykPreview && cmykValues && r !== undefined) { const cmyk = rgbToCMYK(r, g, b); cmykValues.textContent = `C: ${cmyk.c}% M: ${cmyk.m}% Y: ${cmyk.y}% K: ${cmyk.k}%`; cmykPreview.style.display = 'block'; } } else { // Dégradé const type = document.getElementById('gradientTypeSelect').value; const angle = document.getElementById('gradientAngleSlider').value; const color1 = document.getElementById('gradHex1').value || '#667eea'; const color2 = document.getElementById('gradHex2').value || '#764ba2'; if (type === 'linear') { preview.style.background = `linear-gradient(${angle}deg, ${color1} 0%, ${color2} 100%)`; } else { preview.style.background = `radial-gradient(circle, ${color1} 0%, ${color2} 100%)`; } // Afficher CMYK pour les deux couleurs du dégradé if (cmykPreview && cmykValues) { const rgb1 = extractRGB(color1); const rgb2 = extractRGB(color2); if (rgb1 && rgb2) { const cmyk1 = rgbToCMYK(rgb1.r, rgb1.g, rgb1.b); const cmyk2 = rgbToCMYK(rgb2.r, rgb2.g, rgb2.b); cmykValues.innerHTML = `
Début: C${cmyk1.c} M${cmyk1.m} Y${cmyk1.y} K${cmyk1.k}
Fin: C${cmyk2.c} M${cmyk2.m} Y${cmyk2.y} K${cmyk2.k}
`; cmykPreview.style.display = 'block'; } } } } function createSwatchFromModal() { const name = document.getElementById('swatchNameInput').value.trim(); if (!name) { alert('Veuillez entrer un nom pour la couleur.'); return; } const activeType = document.querySelector('.swatch-type-btn.active')?.dataset.type || 'solid'; let color, type; if (activeType === 'solid') { const hexColor = document.getElementById('solidColorHex').value || '#000000'; const opacity = parseInt(document.getElementById('solidOpacitySlider').value) / 100; if (opacity < 1) { // Convertir en rgba const hex = hexColor.slice(1); let r, g, b; if (hex.length === 3) { r = parseInt(hex[0] + hex[0], 16); g = parseInt(hex[1] + hex[1], 16); b = parseInt(hex[2] + hex[2], 16); } else if (hex.length === 6) { r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16); } color = `rgba(${r}, ${g}, ${b}, ${opacity})`; } else { color = hexColor; } type = 'solid'; } else { // Dégradé const gradType = document.getElementById('gradientTypeSelect').value; const angle = document.getElementById('gradientAngleSlider').value; const color1 = document.getElementById('gradHex1').value || '#667eea'; const color2 = document.getElementById('gradHex2').value || '#764ba2'; if (gradType === 'linear') { color = `linear-gradient(${angle}deg, ${color1} 0%, ${color2} 100%)`; } else { color = `radial-gradient(circle, ${color1} 0%, ${color2} 100%)`; } type = 'gradient'; } colorSwatches.push({ name, color, type }); saveSwatches(); renderSwatches(); closeSwatchModal(); } // Initialisation de la modale au chargement function initSwatchCreatorModal() { const modal = document.getElementById('swatchCreatorModal'); if (!modal) return; // Boutons type de couleur document.querySelectorAll('.swatch-type-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.swatch-type-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = '#ddd'; }); btn.classList.add('active'); btn.style.borderColor = '#000'; const type = btn.dataset.type; document.getElementById('solidColorSection').style.display = type === 'solid' ? 'block' : 'none'; document.getElementById('gradientSection').style.display = type === 'gradient' ? 'block' : 'none'; updateSwatchPreview(); }); }); // Couleur unie - picker et hex sync const solidPicker = document.getElementById('solidColorPicker'); const solidHex = document.getElementById('solidColorHex'); solidPicker.addEventListener('input', () => { solidHex.value = solidPicker.value; updateSwatchPreview(); }); solidHex.addEventListener('input', () => { if (/^#[0-9A-Fa-f]{6}$/.test(solidHex.value)) { solidPicker.value = solidHex.value; } updateSwatchPreview(); }); // Opacité const opacitySlider = document.getElementById('solidOpacitySlider'); const opacityValue = document.getElementById('solidOpacityValue'); opacitySlider.addEventListener('input', () => { opacityValue.textContent = opacitySlider.value + '%'; updateSwatchPreview(); }); // Dégradé - type et angle document.getElementById('gradientTypeSelect').addEventListener('change', updateSwatchPreview); const angleSlider = document.getElementById('gradientAngleSlider'); const angleValue = document.getElementById('gradientAngleValue'); angleSlider.addEventListener('input', () => { angleValue.textContent = angleSlider.value + '°'; updateSwatchPreview(); }); // Dégradé - couleurs sync ['gradColor1', 'gradColor2'].forEach((pickerId, idx) => { const picker = document.getElementById(pickerId); const hex = document.getElementById('gradHex' + (idx + 1)); picker.addEventListener('input', () => { hex.value = picker.value; updateSwatchPreview(); }); hex.addEventListener('input', () => { if (/^#[0-9A-Fa-f]{6}$/.test(hex.value)) { picker.value = hex.value; } updateSwatchPreview(); }); }); // Boutons document.getElementById('cancelSwatchBtn').addEventListener('click', closeSwatchModal); document.getElementById('createSwatchBtn').addEventListener('click', createSwatchFromModal); // Fermer avec Escape ou clic sur fond modal.addEventListener('click', (e) => { if (e.target === modal) closeSwatchModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') { closeSwatchModal(); } }); } // ==================== 🎨 FILTERS WIDGET (Instagram-style) ==================== // Définition des filtres (nom + fonction d'application) const imageFilters = { none: { name: 'Original', apply: (imgElement) => { imgElement.filters = []; } }, grayscale: { name: 'Noir & Blanc', apply: (imgElement) => { imgElement.filters = [new fabric.Image.filters.Grayscale()]; } }, sepia: { name: 'Sépia', apply: (imgElement) => { imgElement.filters = [new fabric.Image.filters.Sepia()]; } }, vintage: { name: 'Vintage', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.Sepia(), new fabric.Image.filters.Contrast({ contrast: 0.1 }), new fabric.Image.filters.Brightness({ brightness: -0.05 }) ]; } }, cold: { name: 'Cold', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.HueRotation({ rotation: 0.6 }), new fabric.Image.filters.Saturation({ saturation: 0.3 }) ]; } }, warm: { name: 'Warm', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.HueRotation({ rotation: -0.1 }), new fabric.Image.filters.Brightness({ brightness: 0.05 }) ]; } }, vivid: { name: 'Vivid', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.Saturation({ saturation: 0.6 }), new fabric.Image.filters.Contrast({ contrast: 0.2 }) ]; } }, contrast: { name: 'Contrast', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.Contrast({ contrast: 0.4 }) ]; } }, fade: { name: 'Fade', apply: (imgElement) => { imgElement.filters = [ new fabric.Image.filters.Brightness({ brightness: 0.1 }), new fabric.Image.filters.Contrast({ contrast: -0.2 }) ]; } } }; function setupFiltersWidget() { const widget = document.getElementById('filtersWidget'); const header = document.getElementById('filtersToggle'); const list = document.getElementById('filtersList'); if (!widget || !header || !list) return; // Par défaut, widget fermé (pas de classe expanded) // Toggle expand/collapse header.addEventListener('click', (e) => { if (widget.dataset.dragging === '1') return; widget.classList.toggle('expanded'); // Amener le widget au premier plan bringWidgetToFront(widget); const arrow = header.querySelector('.filters-toggle-arrow'); if (arrow) arrow.textContent = widget.classList.contains('expanded') ? '▴' : '▾'; }); // Gestion des clics sur les filtres document.querySelectorAll('.filter-item').forEach(btn => { btn.addEventListener('click', () => { const filterName = btn.dataset.filter; applyFilterToSelectedImage(filterName); // Mise à jour visuelle du filtre actif document.querySelectorAll('.filter-item').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); // Setup draggable setupDraggableFiltersWidget(); // Écouter les changements de sélection pour afficher/masquer le widget updateFiltersWidgetVisibility(); } function setupDraggableFiltersWidget() { const widget = document.getElementById('filtersWidget'); const header = document.getElementById('filtersToggle'); const container = document.getElementById('canvasScrollArea'); if (!widget || !header || !container) return; // Position par défaut : en bas à gauche (première position) setTimeout(() => { try { const saved = JSON.parse(localStorage.getItem('sp_filters_widget_pos') || 'null'); if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') { widget.style.left = saved.x + 'px'; widget.style.top = saved.y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } else { // Position par défaut : en bas à gauche (premier widget) widget.style.left = '16px'; widget.style.top = 'auto'; widget.style.bottom = '16px'; widget.style.right = 'auto'; } } catch {} }, 100); let isDown = false; let startX = 0, startY = 0, originLeft = 0, originTop = 0; header.addEventListener('mousedown', (e) => { isDown = true; widget.dataset.dragging = '0'; const rect = widget.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; originLeft = rect.left - containerRect.left + container.scrollLeft; originTop = rect.top - containerRect.top + container.scrollTop; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (widget.dataset.dragging !== '1') widget.dataset.dragging = '1'; let x = originLeft + dx; let y = originTop + dy; const contRect = container.getBoundingClientRect(); const wRect = widget.getBoundingClientRect(); const maxX = contRect.width - wRect.width; const maxY = contRect.height - wRect.height; x = Math.max(0, Math.min(x, maxX + container.scrollLeft)); y = Math.max(0, Math.min(y, maxY + container.scrollTop)); widget.style.left = x + 'px'; widget.style.top = y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } }); document.addEventListener('mouseup', () => { if (!isDown) return; isDown = false; const wasDragging = widget.dataset.dragging === '1'; setTimeout(() => { widget.dataset.dragging = '0'; }, 50); document.body.style.userSelect = ''; if (wasDragging) { const left = parseInt(widget.style.left || '16', 10) || 0; const top = parseInt(widget.style.top || '16', 10) || 0; try { localStorage.setItem('sp_filters_widget_pos', JSON.stringify({ x: left, y: top })); } catch {} } }); } function updateFiltersWidgetVisibility(selectedObject) { const placeholder = document.getElementById('filtersPlaceholder'); const content = document.getElementById('filtersContent'); if (!placeholder || !content) return; // Vérifier si l'objet sélectionné est une image (directe ou masquée dans une forme) const isImage = selectedObject && ( (selectedObject.type === 'image' && !selectedObject._isImageMask) || // Image normale (selectedObject._isImageMask && selectedObject._maskImage) // Forme avec image masquée ); if (isImage) { placeholder.style.display = 'none'; content.style.display = 'block'; } else { placeholder.style.display = 'block'; content.style.display = 'none'; } } function applyFilterToSelectedImage(filterName) { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const selected = activeCanvas.getActiveObject(); if (!selected) { alert('Veuillez sélectionner une image.'); return; } // Vérifier si c'est une image directe ou une forme avec image masquée let imageObject = null; if (selected.type === 'image' && !selected._isImageMask) { // Image normale imageObject = selected; } else if (selected._isImageMask && selected._maskImage) { // Forme avec image masquée imageObject = selected._maskImage; } if (!imageObject) { alert('Veuillez sélectionner une image.'); return; } const filter = imageFilters[filterName]; if (!filter) return; // Appliquer le filtre filter.apply(imageObject); imageObject.applyFilters(); activeCanvas.renderAll(); // Sauvegarder le nom du filtre sur l'objet if (selected._isImageMask) { selected.set('_appliedFilter', filterName); } else { imageObject.set('_appliedFilter', filterName); } saveState(`Filtre appliqué: ${filter.name}`); } // Accordéon FAQ (trappes) document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.faq-question').forEach(btn => { btn.addEventListener('click', function() { const answer = btn.nextElementSibling; if (!answer) return; const isOpen = answer.style.display === 'block'; document.querySelectorAll('.faq-answer').forEach(a => a.style.display = 'none'); if (!isOpen) answer.style.display = 'block'; }); }); }); // Init theme, unit, language and auto-login document.addEventListener('DOMContentLoaded', function() { try { // Charger la langue let savedLang = localStorage.getItem('sp_lang') || 'fr'; // Si l'espagnol était précédemment sélectionné, basculer vers l'anglais if (savedLang === 'es') { savedLang = 'en'; localStorage.setItem('sp_lang', 'en'); } setLanguage(savedLang); // Charger le thème const theme = localStorage.getItem('sp_theme'); if (theme) document.documentElement.classList.toggle('theme-dark', theme === 'dark'); // Charger les unités const savedUnit = localStorage.getItem('sp_unit'); if (savedUnit) { currentUnit = savedUnit; // Les boutons d'unités seront mis à jour par updateInterface() } // Auto-login const until = parseInt(localStorage.getItem('sp_login_until') || '0', 10); const ok = localStorage.getItem('sp_login_ok') === '1' && (until === 0 || Date.now() < until); if (ok) { document.getElementById('loginScreen').classList.add('hidden'); document.getElementById('mainApp').style.display = 'flex'; } } catch(e) {} }); document.addEventListener('DOMContentLoaded', function() { // Formats document.getElementById('textSuperscript').addEventListener('click', () => applyTextStyleToSelection('superscript')); document.getElementById('textSubscript').addEventListener('click', () => applyTextStyleToSelection('subscript')); document.getElementById('textUppercase').addEventListener('click', () => applyTextStyleToSelection('uppercase')); document.getElementById('textLowercase').addEventListener('click', () => applyTextStyleToSelection('lowercase')); document.getElementById('textSmallcaps').addEventListener('click', () => applyTextStyleToSelection('smallcaps')); document.getElementById('textStrikethrough').addEventListener('click', () => applyTextStyleToSelection('strikethrough')); document.getElementById('textOverline').addEventListener('click', () => applyTextStyleToSelection('overline')); document.getElementById('textUnderlineHighlight').addEventListener('click', () => applyTextStyleToSelection('underlineHighlight')); document.getElementById('textBothLines').addEventListener('click', () => applyTextStyleToSelection('bothLines')); document.getElementById('textHighlight').addEventListener('click', () => applyTextStyleToSelection('highlight')); document.getElementById('textShadow').addEventListener('click', () => applyTextStyleToSelection('shadow')); // textWide supprimé : bouton A A retiré car fait doublon avec "Esp. lettres" // Options avancées document.getElementById('textStroke').addEventListener('click', () => applyTextStyleToSelection('stroke')); document.getElementById('textOutline').addEventListener('click', () => applyTextStyleToSelection('outline')); document.getElementById('textReset').addEventListener('click', () => applyTextStyleToSelection('reset')); // Alignement entre objets (remplacer les anciens) document.querySelectorAll('[data-align]').forEach(btn => { btn.addEventListener('click', (e) => { alignSelectedObjects(e.target.dataset.align); }); }); // Tidy entre objets document.querySelectorAll('[data-tidy]').forEach(btn => { btn.addEventListener('click', (e) => { tidySelectedObjects(e.target.dataset.tidy); }); }); // Flip (Miroir) horizontal et vertical const flipHorizontalBtn = document.getElementById('flipHorizontal'); const flipVerticalBtn = document.getElementById('flipVertical'); if (flipHorizontalBtn) { flipHorizontalBtn.addEventListener('click', () => { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const obj = activeCanvas.getActiveObject(); if (obj) { obj.set('flipX', !obj.flipX); activeCanvas.renderAll(); saveState('Miroir horizontal'); } }); } if (flipVerticalBtn) { flipVerticalBtn.addEventListener('click', () => { const activeCanvas = getActiveCanvas(); if (!activeCanvas) return; const obj = activeCanvas.getActiveObject(); if (obj) { obj.set('flipY', !obj.flipY); activeCanvas.renderAll(); saveState('Miroir vertical'); } }); } // Persistance des réglages Tidy (écart et unité) const tidyGapEl = document.getElementById('tidyGap'); const tidyUnitEl = document.getElementById('tidyUnit'); if (tidyGapEl && tidyUnitEl) { const savedGap = localStorage.getItem('sp_tidy_gap'); const savedUnit = localStorage.getItem('sp_tidy_unit'); if (savedGap !== null && !isNaN(parseFloat(savedGap))) tidyGapEl.value = savedGap; if (savedUnit) tidyUnitEl.value = savedUnit; tidyGapEl.addEventListener('change', () => { localStorage.setItem('sp_tidy_gap', tidyGapEl.value); }); tidyUnitEl.addEventListener('change', () => { localStorage.setItem('sp_tidy_unit', tidyUnitEl.value); }); } // Styles widget events const widget = document.getElementById('typoStylesWidget'); const toggle = document.getElementById('typoStylesToggle'); const addBtn = document.getElementById('typoStyleAddBtn'); const arrow = toggle ? toggle.querySelector('.typo-toggle-arrow') : null; // Toggle expand on click unless a drag actually occurred if (toggle) toggle.addEventListener('click', () => { if (widget.dataset.dragging === '1') return; widget.classList.toggle('expanded'); // Amener le widget au premier plan bringWidgetToFront(widget); // Inverser la flèche if (arrow) { arrow.textContent = widget.classList.contains('expanded') ? '▴' : '▾'; } }); if (addBtn) addBtn.addEventListener('click', (e) => { e.stopPropagation(); openCreateStyleModal(); }); // Initialize styles from storage and render loadTypographyStyles(); renderTypographyStylesList(); // Draggable widgets setupDraggableStylesWidget(); setupSwatchesWidget(); setupFiltersWidget(); setupPathfinderWidget(); initSwatchCreatorModal(); }); // ========== PATHFINDER WIDGET & BOOLEAN OPERATIONS ========== function setupPathfinderWidget() { const widget = document.getElementById('pathfinderWidget'); const header = document.getElementById('pathfinderToggle'); if (!widget || !header) return; // Toggle expand/collapse header.addEventListener('click', (e) => { if (widget.dataset.dragging === '1') return; widget.classList.toggle('expanded'); bringWidgetToFront(widget); const arrow = header.querySelector('.pathfinder-toggle-arrow'); if (arrow) arrow.textContent = widget.classList.contains('expanded') ? '▴' : '▾'; }); // Event handlers pour les boutons pathfinder document.querySelectorAll('.pathfinder-item').forEach(btn => { btn.addEventListener('click', (e) => { const operation = btn.getAttribute('data-operation'); if (operation) { performPathfinderOperation(operation); } }); }); // Setup draggable setupDraggablePathfinderWidget(); } function setupDraggablePathfinderWidget() { const widget = document.getElementById('pathfinderWidget'); const header = document.getElementById('pathfinderToggle'); const container = document.getElementById('canvasScrollArea'); if (!widget || !header || !container) return; // Restore saved position setTimeout(() => { try { const saved = JSON.parse(localStorage.getItem('sp_pathfinder_widget_pos') || 'null'); if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') { widget.style.left = saved.x + 'px'; widget.style.top = saved.y + 'px'; widget.style.bottom = 'auto'; widget.style.right = 'auto'; } else { widget.style.left = '16px'; widget.style.top = 'auto'; widget.style.bottom = '208px'; widget.style.right = 'auto'; } } catch {} }, 100); let isDown = false; let startX = 0, startY = 0, originLeft = 0, originTop = 0; header.addEventListener('mousedown', (e) => { isDown = true; widget.dataset.dragging = '0'; const rect = widget.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; originLeft = rect.left - containerRect.left + container.scrollLeft; originTop = rect.top - containerRect.top + container.scrollTop; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (widget.dataset.dragging !== '1') widget.dataset.dragging = '1'; let x = originLeft + dx; let y = originTop + dy; const contRect = container.getBoundingClientRect(); const wRect = widget.getBoundingClientRect(); const maxX = contRect.width - wRect.width; const maxY = contRect.height - wRect.height; x = Math.max(0, Math.min(x, maxX + container.scrollLeft)); y = Math.max(0, Math.min(y, maxY + container.scrollTop)); widget.style.left = x + 'px'; widget.style.top = y + 'px'; widget.style.bottom = 'auto'; } }); document.addEventListener('mouseup', () => { if (!isDown) return; isDown = false; const wasDragging = widget.dataset.dragging === '1'; setTimeout(() => { widget.dataset.dragging = '0'; }, 50); document.body.style.userSelect = ''; if (wasDragging) { const left = parseInt(widget.style.left || '16', 10) || 0; const top = parseInt(widget.style.top || '16', 10) || 0; try { localStorage.setItem('sp_pathfinder_widget_pos', JSON.stringify({ x: left, y: top })); } catch {} } }); } function performPathfinderOperation(operation) { const canvas = getActiveCanvas(); if (!canvas) { showNotification('Aucun canvas actif', 'error'); return; } const activeObjects = canvas.getActiveObjects(); if (activeObjects.length < 2) { showNotification('Sélectionnez au moins 2 formes pour utiliser le Pathfinder', 'warning'); return; } const shapes = activeObjects.filter(obj => obj.type !== 'i-text' && obj.type !== 'image' && obj.type !== 'group' ); if (shapes.length < 2) { showNotification('Sélectionnez au moins 2 formes vectorielles', 'warning'); return; } // CRUCIAL : Désélectionner pour avoir les coordonnées absolues canvas.discardActiveObject(); canvas.requestRenderAll(); shapes.forEach(s => s.setCoords()); // SAUVEGARDER LA POSITION ORIGINALE DES FORMES // C'est ici qu'on capture où elles sont VRAIMENT sur la page let globalMinX = Infinity, globalMinY = Infinity; let globalMaxX = -Infinity, globalMaxY = -Infinity; shapes.forEach(s => { const bounds = s.getBoundingRect(true, true); globalMinX = Math.min(globalMinX, bounds.left); globalMinY = Math.min(globalMinY, bounds.top); globalMaxX = Math.max(globalMaxX, bounds.left + bounds.width); globalMaxY = Math.max(globalMaxY, bounds.top + bounds.height); }); // Centre du groupe original (où la forme résultat DOIT être) const originalCenterX = (globalMinX + globalMaxX) / 2; const originalCenterY = (globalMinY + globalMaxY) / 2; console.log('📍 Position originale:', { globalMinX, globalMinY, originalCenterX, originalCenterY }); // Sauvegarder le style de la première forme const baseStyle = { fill: shapes[0].fill || '#000000', stroke: shapes[0].stroke || null, strokeWidth: shapes[0].strokeWidth || 0, opacity: shapes[0].opacity || 1 }; try { // Initialiser Paper.js if (paper.project) paper.project.clear(); const tempCanvas = document.createElement('canvas'); tempCanvas.width = 3000; tempCanvas.height = 3000; paper.setup(tempCanvas); // Convertir les formes const paperPaths = []; for (const shape of shapes) { const pp = fabricToPaperDirect(shape); if (pp) paperPaths.push(pp); } if (paperPaths.length < 2) { showNotification('Impossible de convertir les formes', 'error'); return; } // Opération booléenne let result = paperPaths[0]; for (let i = 1; i < paperPaths.length; i++) { if (!result || !paperPaths[i]) continue; switch (operation) { case 'unite': result = result.unite(paperPaths[i]); break; case 'subtract': result = result.subtract(paperPaths[i]); break; case 'intersect': result = result.intersect(paperPaths[i]); break; case 'exclude': result = result.exclude(paperPaths[i]); break; } } if (!result || result.isEmpty()) { showNotification('L\'opération n\'a produit aucun résultat', 'warning'); return; } // Récupérer la position du résultat dans Paper.js const paperBounds = result.bounds; const paperCenterX = paperBounds.x + paperBounds.width / 2; const paperCenterY = paperBounds.y + paperBounds.height / 2; console.log('📐 Paper.js bounds:', { x: paperBounds.x, y: paperBounds.y, w: paperBounds.width, h: paperBounds.height }); // Extraire les points const segments = []; function extractPoints(item) { if (item.segments) { item.segments.forEach(seg => { segments.push({ x: seg.point.x, y: seg.point.y }); }); } if (item.children) { item.children.forEach(child => extractPoints(child)); } } extractPoints(result); if (segments.length < 3) { showNotification('Forme résultante invalide', 'error'); return; } // Supprimer les originaux AVANT de créer le nouveau shapes.forEach(shape => canvas.remove(shape)); // Créer le Polygon const polygon = new fabric.Polygon(segments, { fill: baseStyle.fill, stroke: baseStyle.stroke, strokeWidth: baseStyle.strokeWidth, opacity: baseStyle.opacity, objectCaching: false }); // Ajouter au canvas canvas.add(polygon); // FORCER LA POSITION : déplacer le centre du polygon vers le centre Paper.js const currentCenter = polygon.getCenterPoint(); console.log('🔴 Fabric center actuel:', currentCenter); console.log('🟢 Paper center cible:', { x: paperCenterX, y: paperCenterY }); // Calculer le décalage const dx = paperCenterX - currentCenter.x; const dy = paperCenterY - currentCenter.y; console.log('➡️ Décalage à appliquer:', { dx, dy }); // Appliquer le décalage polygon.set({ left: polygon.left + dx, top: polygon.top + dy }); polygon.setCoords(); console.log('✅ Position finale:', { left: polygon.left, top: polygon.top }); canvas.setActiveObject(polygon); canvas.requestRenderAll(); saveState('Pathfinder: ' + operation); showNotification('Opération ' + operation + ' réussie', 'success'); } catch (error) { console.error('Erreur Pathfinder:', error); showNotification('Erreur: ' + error.message, 'error'); } } // Conversion directe Fabric -> Paper avec coordonnées absolues function fabricToPaperDirect(obj) { const matrix = obj.calcTransformMatrix(); if (obj.type === 'rect') { const w = obj.width, h = obj.height; const pts = [ fabric.util.transformPoint({ x: -w/2, y: -h/2 }, matrix), fabric.util.transformPoint({ x: w/2, y: -h/2 }, matrix), fabric.util.transformPoint({ x: w/2, y: h/2 }, matrix), fabric.util.transformPoint({ x: -w/2, y: h/2 }, matrix) ]; const path = new paper.Path({ segments: pts.map(p => [p.x, p.y]), closed: true }); return path; } if (obj.type === 'triangle') { const w = obj.width, h = obj.height; const pts = [ fabric.util.transformPoint({ x: 0, y: -h/2 }, matrix), fabric.util.transformPoint({ x: w/2, y: h/2 }, matrix), fabric.util.transformPoint({ x: -w/2, y: h/2 }, matrix) ]; const path = new paper.Path({ segments: pts.map(p => [p.x, p.y]), closed: true }); return path; } if (obj.type === 'circle') { const center = fabric.util.transformPoint({ x: 0, y: 0 }, matrix); const r = (obj.radius || obj.width/2) * (obj.scaleX || 1); return new paper.Path.Circle({ center: [center.x, center.y], radius: r }); } if (obj.type === 'ellipse') { const center = fabric.util.transformPoint({ x: 0, y: 0 }, matrix); const rx = (obj.rx || obj.width/2) * (obj.scaleX || 1); const ry = (obj.ry || obj.height/2) * (obj.scaleY || 1); const ellipse = new paper.Path.Ellipse({ center: [center.x, center.y], radius: [rx, ry] }); if (obj.angle) ellipse.rotate(obj.angle, [center.x, center.y]); return ellipse; } if (obj.type === 'polygon' && obj.points) { const pts = obj.points.map(p => { const local = { x: p.x - obj.pathOffset.x, y: p.y - obj.pathOffset.y }; return fabric.util.transformPoint(local, matrix); }); return new paper.Path({ segments: pts.map(p => [p.x, p.y]), closed: true }); } // Fallback: bounding rect const bounds = obj.getBoundingRect(true, true); return new paper.Path.Rectangle({ point: [bounds.left, bounds.top], size: [bounds.width, bounds.height] }); } // Conversion Paper -> Fabric avec position ABSOLUE préservée function paperToFabricDirect(paperPath, style) { // Récupérer les segments du path Paper.js const segments = []; // Fonction pour extraire les points d'un path Paper function extractPoints(item) { if (item.segments) { item.segments.forEach(seg => { segments.push({ x: seg.point.x, y: seg.point.y }); }); } if (item.children) { item.children.forEach(child => extractPoints(child)); } } extractPoints(paperPath); if (segments.length < 3) { console.error('Pas assez de points pour créer une forme'); return null; } // Calculer le bounding box des points let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; segments.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); // Centre du bounding box (position cible sur le canvas) const targetCenterX = (minX + maxX) / 2; const targetCenterY = (minY + maxY) / 2; // Créer le Polygon const polygon = new fabric.Polygon(segments, { fill: style.fill, stroke: style.stroke, strokeWidth: style.strokeWidth, opacity: style.opacity, objectCaching: false }); // FORCER LA POSITION AU BON ENDROIT // Fabric.Polygon calcule son propre centre, on doit le repositionner const fabricCenter = polygon.getCenterPoint(); // Calculer le décalage nécessaire const offsetX = targetCenterX - fabricCenter.x; const offsetY = targetCenterY - fabricCenter.y; // Appliquer le décalage polygon.set({ left: polygon.left + offsetX, top: polygon.top + offsetY }); polygon.setCoords(); return polygon; } function findPathItemInGroup(group) { if (group.children) { for (let child of group.children) { if (child instanceof paper.Path || child instanceof paper.CompoundPath) { return child; } if (child instanceof paper.Group) { const found = findPathItemInGroup(child); if (found) return found; } } } return null; } // ========== API SUPERPRINT & OUTILS DÉVELOPPEUR ========== // API SuperPrint pour accès programmatique window.SuperPrint = { // Ajouter du texte addText: function(x, y, text, options = {}) { const canvas = getActiveCanvas(); if (!canvas) throw new Error('Aucun canvas actif'); const textObj = new fabric.Textbox(text, { left: mmToPx(x), top: mmToPx(y), width: mmToPx(options.width || 100), fontSize: options.fontSize || 14, fontFamily: options.fontFamily || 'Inter', fontWeight: options.fontWeight || 'normal', fill: options.fill || '#000000', textAlign: options.textAlign || 'left' }); canvas.add(textObj); canvas.renderAll(); saveState('Texte ajouté via API'); return textObj; }, // Ajouter une image addImage: function(x, y, imageUrl, options = {}) { return new Promise((resolve, reject) => { const canvas = getActiveCanvas(); if (!canvas) { reject(new Error('Aucun canvas actif')); return; } fabric.Image.fromURL(imageUrl, function(img) { img.set({ left: mmToPx(x), top: mmToPx(y), scaleX: options.scaleX || 1, scaleY: options.scaleY || 1 }); canvas.add(img); canvas.renderAll(); saveState('Image ajoutée via API'); resolve(img); }); }); }, // Ajouter un rectangle addRect: function(x, y, width, height, options = {}) { const canvas = getActiveCanvas(); if (!canvas) throw new Error('Aucun canvas actif'); const rect = new fabric.Rect({ left: mmToPx(x), top: mmToPx(y), width: mmToPx(width), height: mmToPx(height), fill: options.fill || '#cccccc', stroke: options.stroke || '#000000', strokeWidth: options.strokeWidth || 1 }); canvas.add(rect); canvas.renderAll(); saveState('Rectangle ajouté via API'); return rect; }, // Obtenir toutes les pages getPages: function() { return pages.map((page, index) => ({ index: index, objects: page.objects, active: index === currentPageIndex })); }, // Changer de page active setPage: function(pageIndex) { if (pageIndex >= 0 && pageIndex < pages.length) { goToPage(pageIndex); return true; } return false; }, // Exporter en PDF exportPDF: function(options = {}) { const modal = document.getElementById('exportModal'); // Définir les options if (options.quality) { document.querySelector(`input[name="exportQuality"][value="${options.quality}"]`).checked = true; } if (options.colorMode) { document.querySelector(`input[name="colorMode"][value="${options.colorMode}"]`).checked = true; } // Déclencher l'export return confirmExport(); }, // Sauvegarder le projet saveProject: function() { saveAllPages(); const projectData = { version: "1.0", pageFormat: pageFormat, pages: pages, settings: { margin, bleed } }; return JSON.stringify(projectData, null, 2); }, // Charger un projet loadProject: function(jsonData) { try { const project = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData; if (project.pageFormat) { pageFormat = project.pageFormat; document.getElementById('pageWidth').value = pageFormat.width; document.getElementById('pageHeight').value = pageFormat.height; } if (project.pages) { pages = project.pages; currentPageIndex = 0; renderAllPages(); updatePageIndicator(); saveState('Projet chargé via API'); } return true; } catch (error) { console.error('Erreur de chargement:', error); return false; } } }; // Gestion de la modal outils développeur function openDeveloperModal() { document.getElementById('developerModal').classList.add('active'); } function closeDeveloperModal() { document.getElementById('developerModal').classList.remove('active'); } // Copier l'exemple d'API function copyApiExample() { const example = document.getElementById('apiExample'); navigator.clipboard.writeText(example.value).then(() => { alert('Exemple copié dans le presse-papier !'); }); } // Exécuter l'exemple d'API function runApiExample() { try { // Exemple basique SuperPrint.addText(50, 50, "Titre automatique", { fontSize: 24, fontWeight: 'bold', fill: '#ff6b35' }); SuperPrint.addRect(50, 100, 200, 150, { fill: '#4299e1', stroke: '#2b6cb0' }); document.getElementById('scriptResult').innerHTML = '✅ Exemple exécuté avec succès !'; document.getElementById('scriptOutput').style.display = 'block'; } catch (error) { document.getElementById('scriptResult').innerHTML = `❌ Erreur: ${error.message}`; document.getElementById('scriptOutput').style.display = 'block'; } } // Scripts prédéfinis const predefinedScripts = { generateGrid: `// Générer une grille de guides for(let x = 0; x <= 210; x += 30) { for(let y = 0; y <= 297; y += 30) { if(x > 0) SuperPrint.addRect(x-0.5, 0, 1, 297, {fill: '#2a2a2a', strokeWidth: 0}); if(y > 0) SuperPrint.addRect(0, y-0.5, 210, 1, {fill: '#2a2a2a', strokeWidth: 0}); } } console.log('Grille 30mm générée');`, bulkText: `// Création de textes en lot const titles = ['Titre 1', 'Titre 2', 'Titre 3', 'Titre 4']; titles.forEach((title, i) => { SuperPrint.addText(20, 50 + (i * 40), title, { fontSize: 18, fontWeight: 'bold', fill: '#2c3e50' }); }); console.log('Textes en lot créés');`, autoLayout: `// Layout automatique en colonnes const texts = ['Colonne 1', 'Colonne 2', 'Colonne 3']; const colWidth = 60; texts.forEach((text, i) => { SuperPrint.addRect(20 + (i * colWidth), 50, colWidth - 5, 100, { fill: '#ecf0f1', stroke: '#bdc3c7' }); SuperPrint.addText(25 + (i * colWidth), 60, text, { fontSize: 12, fontWeight: 'bold' }); }); console.log('Layout automatique créé');`, batchExport: `// Export en lot (différentes qualités) const qualities = ['standard', 'medium', 'hd']; qualities.forEach(quality => { SuperPrint.exportPDF({quality: quality, colorMode: 'rgb'}); }); console.log('Export en lot lancé');`, colorTheme: `// Appliquer thème couleur const canvas = getActiveCanvas(); const objects = canvas.getObjects(); const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12']; objects.forEach((obj, i) => { if(obj.fill) { obj.set('fill', colors[i % colors.length]); } }); canvas.renderAll(); console.log('Thème couleur appliqué');` }; // Charger un script prédéfini function loadPredefinedScript() { const select = document.getElementById('predefinedScripts'); const scriptKey = select.value; if (scriptKey && predefinedScripts[scriptKey]) { document.getElementById('customScript').value = predefinedScripts[scriptKey]; } } // Exécuter le script personnalisé function executeScript() { const script = document.getElementById('customScript').value; if (!script.trim()) { alert('Veuillez entrer un script à exécuter'); return; } try { // Créer un contexte sécurisé const result = new Function(script)(); document.getElementById('scriptResult').innerHTML = `✅ Script exécuté avec succès !
Résultat: ${result || 'undefined'}`; document.getElementById('scriptOutput').style.display = 'block'; } catch (error) { document.getElementById('scriptResult').innerHTML = `❌ Erreur d'exécution: ${error.message}`; document.getElementById('scriptOutput').style.display = 'block'; } } // Effacer le script function clearScript() { document.getElementById('customScript').value = ''; document.getElementById('scriptOutput').style.display = 'none'; } // Sauvegarder le script function saveScript() { const script = document.getElementById('customScript').value; if (script.trim()) { const blob = new Blob([script], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `superprint-script-${Date.now()}.js`; a.click(); URL.revokeObjectURL(url); } } // Event listener pour le bouton outils développeur document.addEventListener('DOMContentLoaded', function() { const devButton = document.getElementById('openDeveloperTools'); if (devButton) { devButton.addEventListener('click', openDeveloperModal); } }); // Ergonomie: swipe/drag pour la barre d'assets (#designScroll) document.addEventListener('DOMContentLoaded', function() { const designScroll = document.getElementById('designScroll'); if (!designScroll) return; // Molette verticale = scroll horizontal designScroll.addEventListener('wheel', (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault(); designScroll.scrollLeft += e.deltaY; } }, { passive: false }); // Drag to scroll (pointer events) let isPanning = false; let startX = 0; let startLeft = 0; designScroll.addEventListener('pointerdown', (e) => { isPanning = true; startX = e.clientX; startLeft = designScroll.scrollLeft; designScroll.style.cursor = 'grabbing'; designScroll.setPointerCapture && designScroll.setPointerCapture(e.pointerId); }); designScroll.addEventListener('pointermove', (e) => { if (!isPanning) return; const dx = e.clientX - startX; designScroll.scrollLeft = startLeft - dx; }); const endPan = (e) => { if (!isPanning) return; isPanning = false; designScroll.style.cursor = ''; designScroll.releasePointerCapture && designScroll.releasePointerCapture(e.pointerId); }; designScroll.addEventListener('pointerup', endPan); designScroll.addEventListener('pointercancel', endPan); designScroll.addEventListener('pointerleave', endPan); // Scroll arrows visibility and click behavior const leftBtn = document.getElementById('assetsScrollLeft'); const rightBtn = document.getElementById('assetsScrollRight'); function updateArrowVisibility() { const maxScroll = designScroll.scrollWidth - designScroll.clientWidth; const atStart = designScroll.scrollLeft <= 2; const atEnd = designScroll.scrollLeft >= maxScroll - 2; if (leftBtn) leftBtn.style.display = atStart ? 'none' : 'flex'; if (rightBtn) rightBtn.style.display = atEnd ? 'none' : 'flex'; } if (leftBtn) leftBtn.addEventListener('click', () => designScroll.scrollBy({ left: -400, behavior: 'smooth' })); if (rightBtn) rightBtn.addEventListener('click', () => designScroll.scrollBy({ left: 400, behavior: 'smooth' })); designScroll.addEventListener('scroll', updateArrowVisibility, { passive: true }); window.addEventListener('resize', updateArrowVisibility); setTimeout(updateArrowVisibility, 50); // Show arrows on hover of the panel content const trappeContent = document.getElementById('trappeContent'); if (trappeContent) { trappeContent.addEventListener('mouseenter', updateArrowVisibility); trappeContent.addEventListener('mousemove', updateArrowVisibility); } }); // ==================== ASSET (BIBLIOTHÈQUE DE RESSOURCES) ==================== // Patterns typo prédéfinis const typographyPatterns = [ { name: 'Titre XL', objects: [ { type: 'text', text: 'TITRE PRINCIPAL', fontSize: 56, fontWeight: '700', fontFamily: 'Poppins' } ]}, { name: 'H1 + Deck', objects: [ { type: 'text', text: 'GRAND TITRE', fontSize: 42, fontWeight: '700', fontFamily: 'Poppins' }, { type: 'text', text: 'Accroche explicative en 1-2 lignes pour introduire le sujet.', top: 50, width: 320, fontSize: 16, fontFamily: 'Inter', fill: '#666' } ]}, { name: 'H1 + H2 + Body', objects: [ { type: 'text', text: 'Titre', fontSize: 36, fontWeight: '700', fontFamily: 'Poppins' }, { type: 'text', text: 'Sous-titre', top: 42, fontSize: 20, fontWeight: '500', fontFamily: 'Playfair Display' }, { type: 'text', text: 'Corps de texte sur plusieurs lignes, avec une largeur pour simuler un bloc.', top: 72, width: 320, fontSize: 12, fontFamily: 'Inter', lineHeight: 1.4 } ]}, { name: 'Citation', objects: [ { type: 'text', text: '“On n\'apprend bien que par l\'exemple.”', fontSize: 28, fontStyle: 'italic', fontFamily: 'Playfair Display', width: 300 } ]}, { name: 'Légende + Crédit', objects: [ { type: 'text', text: 'Légende de l\'image', fontSize: 10, fontFamily: 'Inter', fill: '#666' }, { type: 'text', text: '© Auteur', top: 14, fontSize: 9, fontFamily: 'IBM Plex Mono', fill: '#999' } ]}, { name: 'Bloc code', objects: [ { type: 'text', text: 'const hello = "world";', fontSize: 12, fontFamily: 'JetBrains Mono', fill: '#0b7285', width: 280 } ]}, { name: 'Grille article 2 colonnes', objects: [ { type: 'text', text: 'Titre de l\'article', fontSize: 30, fontWeight: '700', fontFamily: 'Poppins' }, { type: 'text', text: 'Chapeau introductif...', top: 38, width: 360, fontSize: 14, fontFamily: 'Inter', fill: '#444' }, { type: 'text', text: 'Colonne 1 texte lorem ipsum dolor sit amet...', top: 76, width: 170, fontSize: 12, fontFamily: 'Inter', lineHeight: 1.45 }, { type: 'text', text: 'Colonne 2 texte lorem ipsum dolor sit amet...', top: 76, left: 190, width: 170, fontSize: 12, fontFamily: 'Inter', lineHeight: 1.45 } ]}, { name: 'Affiche big type', objects: [ { type: 'text', text: 'SALE', fontSize: 96, fontWeight: '800', fontFamily: 'Bebas Neue', letterSpacing: 1 }, { type: 'text', text: 'JUSQU\'À -50%', top: 82, fontSize: 24, fontFamily: 'Inter', fontWeight: '700' } ]} ]; // Images exemple (placeholders) const imageExamples = [ { name: 'Placeholder 1', url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VlZSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiM5OTkiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JTUFHRTwvdGV4dD48L3N2Zz4=' }, { name: 'Placeholder 2', url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2RkZCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiM3NzciIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5QSE9UTzwvdGV4dD48L3N2Zz4=' }, { name: 'Placeholder 3', url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2NjYyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiM2NjYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5WSVNVRUQ8L3RleHQ+PC9zdmc+' } ]; // Vecteurs exemple const vectorExamples = [ // Basic shapes { name: 'Rectangle', type: 'rect', width: 120, height: 80, fill: '#3498db' }, { name: 'Carré', type: 'rect', width: 80, height: 80, fill: '#2ecc71' }, { name: 'Cercle', type: 'circle', radius: 42, fill: '#e74c3c' }, { name: 'Ellipse', type: 'ellipse', rx: 60, ry: 36, fill: '#9b59b6' }, { name: 'Triangle', type: 'triangle', width: 90, height: 80, fill: '#f39c12' }, { name: 'Étoile', type: 'star', points: 5, radius: 40, fill: '#f1c40f' }, // Icons and simple vectors { name: 'Play', type: 'polygon', points: [ {x:0,y:0}, {x:44,y:22}, {x:0,y:44} ], fill: '#111' }, { name: 'Chevron', type: 'polygon', points: [ {x:0,y:0}, {x:20,y:20}, {x:0,y:40} ], fill: '#333' }, { name: 'Heart', type: 'path', path: 'M24 42s-1.7-1.5-3.6-3.3C14 33.7 8 28.2 8 21.5 8 16.2 12.2 12 17.5 12c3 0 5.8 1.4 7.5 3.6C26.7 13.4 29.5 12 32.5 12 37.8 12 42 16.2 42 21.5c0 6.7-6 12.2-12.4 17.2C25.7 40.5 24 42 24 42z', scale: 1, fill: '#e25555' }, { name: 'Link', type: 'path', path: 'M17 7a7 7 0 0 1 10 0l2 2a7 7 0 0 1 0 10l-4 4a7 7 0 0 1-10 0l-1-1 2-2 1 1a4 4 0 0 0 6 0l4-4a4 4 0 0 0 0-6l-2-2a4 4 0 0 0-6 0l-2 2-2-2 2-2z', scale: 1, fill: '#111' }, { name: 'Check', type: 'path', path: 'M5 20l8 8 18-18', stroke: '#27ae60', strokeWidth: 4, fill: null }, { name: 'Close', type: 'path', path: 'M5 5L35 35M35 5L5 35', stroke: '#e74c3c', strokeWidth: 4, fill: null }, // UI icons { name: 'Menu', type: 'group', items: [ { type: 'rect', width: 38, height: 4, left: 1, top: 6, fill: '#333' }, { type: 'rect', width: 38, height: 4, left: 1, top: 18, fill: '#333' }, { type: 'rect', width: 38, height: 4, left: 1, top: 30, fill: '#333' } ]}, { name: 'User', type: 'group', items: [ { type: 'circle', radius: 12, left: 20, top: 8, fill: '#444' }, { type: 'rect', width: 40, height: 22, left: 0, top: 28, rx: 11, ry: 11, fill: '#444' } ]}, { name: 'Search', type: 'group', items: [ { type: 'circle', radius: 12, left: 4, top: 4, fill: 'transparent', stroke: '#2c3e50', strokeWidth: 3 }, { type: 'rect', width: 12, height: 4, left: 20, top: 20, rx: 2, ry: 2, fill: '#2c3e50' } ]}, { name: 'Home', type: 'polygon', points: [{x:0,y:18},{x:20,y:0},{x:40,y:18},{x:40,y:40},{x:26,y:40},{x:26,y:26},{x:14,y:26},{x:14,y:40},{x:0,y:40}], fill: '#2c3e50' }, { name: 'Camera', type: 'group', items: [ { type: 'rect', width: 40, height: 26, left: 0, top: 10, rx: 4, ry: 4, fill: '#444' }, { type: 'circle', radius: 8, left: 20, top: 23, fill: '#fff' }, { type: 'rect', width: 14, height: 6, left: 4, top: 6, fill: '#444' } ]}, { name: 'Tag', type: 'polygon', points: [{x:0,y:10},{x:20,y:0},{x:40,y:10},{x:20,y:20}], fill: '#8e44ad' }, { name: 'Bell', type: 'group', items: [ { type: 'path', path: 'M20 6c-6 0-10 4-10 10v8H30v-8c0-6-4-10-10-10z', fill: '#333' }, { type: 'circle', radius: 2, left: 20, top: 34, fill: '#333' } ]}, { name: 'Chat', type: 'path', path: 'M4 6h32v18H16l-8 6v-6H4z', fill: '#2980b9' }, { name: 'Cloud', type: 'path', path: 'M10 26h24a8 8 0 1 0-3-15 10 10 0 0 0-19 4 6 6 0 1 0-2 11z', fill: '#95a5a6' }, { name: 'Sun', type: 'group', items: [ { type: 'circle', radius: 10, left: 20, top: 20, fill: '#f39c12' }, { type: 'path', path: 'M20 0v10M20 34v10M0 20h10M34 20h10M6 6l7 7M27 27l7 7M6 34l7-7M27 13l7-7', stroke: '#f1c40f', strokeWidth: 2, fill: null } ]}, { name: 'Moon', type: 'path', path: 'M28 36a14 14 0 1 1 0-28 12 12 0 1 0 0 28z', fill: '#bdc3c7' }, { name: 'Bolt', type: 'polygon', points: [{x:10,y:0},{x:20,y:0},{x:12,y:18},{x:22,y:18},{x:8,y:40},{x:16,y:20},{x:8,y:20}], fill: '#f1c40f' } ]; // Layouts exemple - Collection professionnelle enrichie const compositionExamples = [ // === CARTES DE VISITE === { name: 'Carte Minimaliste', key: 'card_minimal', build: (canvas, x, y) => { const objs = []; const bg = new fabric.Rect({ left: x, top: y, width: 240, height: 140, fill: '#ffffff', stroke: '#000', strokeWidth: 0.5 }); const name = new fabric.Textbox('PRÉNOM NOM', { left: x+20, top: y+20, fontSize: 16, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 100 }); const title = new fabric.Textbox('Designer Graphique', { left: x+20, top: y+42, fontSize: 9, fontFamily: 'Inter', fill: '#666' }); const line = new fabric.Line([x+20, y+62, x+220, y+62], { stroke: '#000', strokeWidth: 0.5 }); const email = new fabric.Textbox('hello@email.com', { left: x+20, top: y+75, fontSize: 8, fontFamily: 'IBM Plex Mono' }); const phone = new fabric.Textbox('+33 6 12 34 56 78', { left: x+20, top: y+90, fontSize: 8, fontFamily: 'IBM Plex Mono' }); const web = new fabric.Textbox('www.portfolio.com', { left: x+20, top: y+105, fontSize: 8, fontFamily: 'IBM Plex Mono' }); objs.push(bg, name, title, line, email, phone, web); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Carte Minimaliste'); }}, { name: 'Carte Bold', key: 'card_bold', build: (canvas, x, y) => { const objs = []; const bgBlack = new fabric.Rect({ left: x, top: y, width: 120, height: 140, fill: '#000' }); const bgWhite = new fabric.Rect({ left: x+120, top: y, width: 120, height: 140, fill: '#fff', stroke: '#000', strokeWidth: 0.5 }); const name = new fabric.Textbox('NOM', { left: x+10, top: y+20, width: 100, fontSize: 20, fontWeight: '900', fontFamily: 'Bebas Neue', fill: '#fff', angle: 0 }); const title = new fabric.Textbox('Directeur\nCréatif', { left: x+130, top: y+20, width: 100, fontSize: 11, fontWeight: '700', fontFamily: 'Poppins', lineHeight: 1.2 }); const contact = new fabric.Textbox('E\nT\nW', { left: x+10, top: y+80, fontSize: 7, fontFamily: 'IBM Plex Mono', fill: '#fff', lineHeight: 2 }); const details = new fabric.Textbox('hello@studio.com\n+33 6 12 34 56 78\nstudio.com', { left: x+130, top: y+80, width: 100, fontSize: 7, fontFamily: 'IBM Plex Mono', lineHeight: 2 }); objs.push(bgBlack, bgWhite, name, title, contact, details); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Carte Bold'); }}, { name: 'Carte Élégante', key: 'card_elegant', build: (canvas, x, y) => { const objs = []; const bg = new fabric.Rect({ left: x, top: y, width: 240, height: 140, fill: '#f8f8f8' }); const accent = new fabric.Rect({ left: x, top: y, width: 4, height: 140, fill: '#0088ff' }); const name = new fabric.Textbox('Prénom Nom', { left: x+20, top: y+30, fontSize: 18, fontWeight: '400', fontFamily: 'Playfair Display' }); const title = new fabric.Textbox('CONSULTANT SENIOR', { left: x+20, top: y+52, fontSize: 8, fontFamily: 'Inter', letterSpacing: 80, fill: '#666' }); const divider = new fabric.Rect({ left: x+20, top: y+68, width: 40, height: 1, fill: '#0088ff' }); const email = new fabric.Textbox('prenom.nom@conseil.fr', { left: x+20, top: y+82, fontSize: 8, fontFamily: 'Inter' }); const phone = new fabric.Textbox('+33 1 23 45 67 89', { left: x+20, top: y+95, fontSize: 8, fontFamily: 'Inter' }); const address = new fabric.Textbox('Paris, France', { left: x+20, top: y+108, fontSize: 7, fontFamily: 'Inter', fill: '#999' }); objs.push(bg, accent, name, title, divider, email, phone, address); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Carte Élégante'); }}, // === CV / RESUMES === { name: 'CV Moderne', key: 'cv_modern', build: (canvas, x, y) => { const objs = []; const header = new fabric.Rect({ left: x, top: y, width: 400, height: 80, fill: '#000' }); const name = new fabric.Textbox('PRÉNOM NOM', { left: x+20, top: y+20, fontSize: 26, fontWeight: '900', fontFamily: 'Poppins', fill: '#fff', letterSpacing: 100 }); const job = new fabric.Textbox('Designer UX/UI • Développeur Frontend', { left: x+20, top: y+50, fontSize: 11, fontFamily: 'Inter', fill: '#ccc' }); const sidebar = new fabric.Rect({ left: x, top: y+80, width: 120, height: 480, fill: '#f5f5f5' }); const mainArea = new fabric.Rect({ left: x+120, top: y+80, width: 280, height: 480, fill: '#fff' }); const contactTitle = new fabric.Textbox('CONTACT', { left: x+10, top: y+95, fontSize: 10, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 50 }); const contact = new fabric.Textbox('hello@email.com\n+33 6 12 34 56 78\nParis, France\nlinkedin.com/in/nom', { left: x+10, top: y+115, width: 100, fontSize: 8, fontFamily: 'Inter', lineHeight: 1.8 }); const skillsTitle = new fabric.Textbox('COMPÉTENCES', { left: x+10, top: y+190, fontSize: 10, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 50 }); const skills = new fabric.Textbox('• Figma, Adobe XD\n• React, Vue.js\n• HTML/CSS/JS\n• Git, Agile', { left: x+10, top: y+210, width: 100, fontSize: 8, fontFamily: 'Inter', lineHeight: 1.8 }); const expTitle = new fabric.Textbox('EXPÉRIENCE', { left: x+130, top: y+95, fontSize: 12, fontWeight: '700', fontFamily: 'Inter' }); const exp1 = new fabric.Textbox('Designer UX/UI Senior\nStudio Créatif • 2022-2025\nConception d\'interfaces pour applications mobiles et web.', { left: x+130, top: y+115, width: 260, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.5 }); const exp2 = new fabric.Textbox('Designer UX/UI\nAgence Digital • 2019-2022\nRecherche utilisateur, wireframing, prototypage.', { left: x+130, top: y+180, width: 260, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.5 }); const eduTitle = new fabric.Textbox('FORMATION', { left: x+130, top: y+250, fontSize: 12, fontWeight: '700', fontFamily: 'Inter' }); const edu = new fabric.Textbox('Master Design Interactif\nÉcole Supérieure • 2017-2019', { left: x+130, top: y+270, width: 260, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.5 }); objs.push(header, name, job, sidebar, mainArea, contactTitle, contact, skillsTitle, skills, expTitle, exp1, exp2, eduTitle, edu); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('CV Moderne'); }}, { name: 'CV Minimal', key: 'cv_minimal', build: (canvas, x, y) => { const objs = []; const name = new fabric.Textbox('Prénom Nom', { left: x+20, top: y+20, fontSize: 32, fontWeight: '300', fontFamily: 'Playfair Display' }); const title = new fabric.Textbox('ARCHITECTE D\'INTÉRIEUR', { left: x+20, top: y+58, fontSize: 9, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 100, fill: '#666' }); const line1 = new fabric.Line([x+20, y+78, x+420, y+78], { stroke: '#ddd', strokeWidth: 1 }); const contact = new fabric.Textbox('prenom.nom@email.com • +33 6 12 34 56 78 • Paris, France', { left: x+20, top: y+88, fontSize: 8, fontFamily: 'Inter', fill: '#999' }); const expTitle = new fabric.Textbox('Expérience Professionnelle', { left: x+20, top: y+120, fontSize: 14, fontWeight: '600', fontFamily: 'Inter' }); const exp1 = new fabric.Textbox('Architecte Senior • Cabinet d\'Architecture XY\n2020 - Présent\nConception et direction de projets résidentiels haut de gamme.', { left: x+20, top: y+145, width: 400, fontSize: 10, fontFamily: 'Inter', lineHeight: 1.6 }); const exp2 = new fabric.Textbox('Architecte Junior • Studio Design Z\n2017 - 2020\nAménagement d\'espaces commerciaux et bureaux.', { left: x+20, top: y+210, width: 400, fontSize: 10, fontFamily: 'Inter', lineHeight: 1.6 }); const eduTitle = new fabric.Textbox('Formation', { left: x+20, top: y+275, fontSize: 14, fontWeight: '600', fontFamily: 'Inter' }); const edu = new fabric.Textbox('Master Architecture d\'Intérieur • École Nationale Supérieure\n2015 - 2017', { left: x+20, top: y+300, width: 400, fontSize: 10, fontFamily: 'Inter', lineHeight: 1.6 }); objs.push(name, title, line1, contact, expTitle, exp1, exp2, eduTitle, edu); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('CV Minimal'); }}, // === DOCUMENTS COMMERCIAUX === { name: 'Devis Pro', key: 'quote_pro', build: (canvas, x, y) => { const objs = []; const header = new fabric.Rect({ left: x, top: y, width: 400, height: 60, fill: '#0088ff' }); const title = new fabric.Textbox('DEVIS', { left: x+20, top: y+15, fontSize: 28, fontWeight: '700', fontFamily: 'Poppins', fill: '#fff' }); const docNum = new fabric.Textbox('N° DEV-2025-001', { left: x+280, top: y+25, fontSize: 10, fontFamily: 'IBM Plex Mono', fill: '#fff' }); const clientLabel = new fabric.Textbox('CLIENT', { left: x+20, top: y+75, fontSize: 9, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 80 }); const clientInfo = new fabric.Textbox('Nom du client\nAdresse complète\n75001 Paris', { left: x+20, top: y+92, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.6 }); const dateLabel = new fabric.Textbox('DATE', { left: x+280, top: y+75, fontSize: 9, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 80 }); const dateInfo = new fabric.Textbox('19 octobre 2025\nValable 30 jours', { left: x+280, top: y+92, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.6 }); const tableHeader = new fabric.Rect({ left: x+20, top: y+140, width: 360, height: 25, fill: '#f5f5f5' }); const thDesc = new fabric.Textbox('DESCRIPTION', { left: x+28, top: y+147, fontSize: 8, fontWeight: '700', fontFamily: 'Inter' }); const thQty = new fabric.Textbox('QTÉ', { left: x+240, top: y+147, fontSize: 8, fontWeight: '700', fontFamily: 'Inter' }); const thPU = new fabric.Textbox('P.U.', { left: x+290, top: y+147, fontSize: 8, fontWeight: '700', fontFamily: 'Inter' }); const thTotal = new fabric.Textbox('TOTAL', { left: x+340, top: y+147, fontSize: 8, fontWeight: '700', fontFamily: 'Inter' }); const table = new fabric.Rect({ left: x+20, top: y+165, width: 360, height: 150, fill: '#fff', stroke: '#e0e0e0', strokeWidth: 1 }); const subtotal = new fabric.Textbox('Sous-total HT', { left: x+240, top: y+325, fontSize: 9, fontFamily: 'Inter', fill: '#666' }); const subtotalVal = new fabric.Textbox('0,00 €', { left: x+330, top: y+325, fontSize: 9, fontWeight: '700', fontFamily: 'Inter' }); const tva = new fabric.Textbox('TVA 20%', { left: x+240, top: y+342, fontSize: 9, fontFamily: 'Inter', fill: '#666' }); const tvaVal = new fabric.Textbox('0,00 €', { left: x+330, top: y+342, fontSize: 9, fontWeight: '700', fontFamily: 'Inter' }); const totalBg = new fabric.Rect({ left: x+220, top: y+358, width: 160, height: 28, fill: '#0088ff' }); const totalLabel = new fabric.Textbox('TOTAL TTC', { left: x+230, top: y+365, fontSize: 11, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); const totalVal = new fabric.Textbox('0,00 €', { left: x+320, top: y+365, fontSize: 11, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); objs.push(header, title, docNum, clientLabel, clientInfo, dateLabel, dateInfo, tableHeader, thDesc, thQty, thPU, thTotal, table, subtotal, subtotalVal, tva, tvaVal, totalBg, totalLabel, totalVal); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Devis Pro'); }}, { name: 'Facture Élégante', key: 'invoice_elegant', build: (canvas, x, y) => { const objs = []; const logo = new fabric.Rect({ left: x+20, top: y+20, width: 60, height: 60, fill: '#000' }); const logoText = new fabric.Textbox('LOGO', { left: x+28, top: y+38, fontSize: 12, fontWeight: '700', fontFamily: 'Poppins', fill: '#fff' }); const company = new fabric.Textbox('VOTRE SOCIÉTÉ\nAdresse complète\n75001 Paris\ncontact@societe.com', { left: x+95, top: y+20, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.6 }); const invoiceTitle = new fabric.Textbox('FACTURE', { left: x+280, top: y+20, fontSize: 24, fontWeight: '700', fontFamily: 'Playfair Display' }); const invoiceNum = new fabric.Textbox('N° FACT-2025-001\nDate: 19/10/2025\nÉchéance: 19/11/2025', { left: x+280, top: y+50, fontSize: 8, fontFamily: 'IBM Plex Mono', lineHeight: 1.8 }); const divider = new fabric.Line([x+20, y+100, x+400, y+100], { stroke: '#000', strokeWidth: 2 }); const clientBox = new fabric.Rect({ left: x+20, top: y+115, width: 180, height: 70, fill: '#f9f9f9', stroke: '#e0e0e0', strokeWidth: 1 }); const clientLabel = new fabric.Textbox('FACTURÉ À', { left: x+30, top: y+122, fontSize: 8, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 80 }); const clientInfo = new fabric.Textbox('Nom du client\nAdresse\nVille, Code Postal', { left: x+30, top: y+138, fontSize: 9, fontFamily: 'Inter', lineHeight: 1.6 }); const tableTop = y+200; const tableHeader2 = new fabric.Rect({ left: x+20, top: tableTop, width: 380, height: 22, fill: '#000' }); const th1 = new fabric.Textbox('DESCRIPTION', { left: x+28, top: tableTop+6, fontSize: 8, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); const th2 = new fabric.Textbox('QTÉ', { left: x+260, top: tableTop+6, fontSize: 8, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); const th3 = new fabric.Textbox('P.U.', { left: x+310, top: tableTop+6, fontSize: 8, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); const th4 = new fabric.Textbox('TOTAL', { left: x+360, top: tableTop+6, fontSize: 8, fontWeight: '700', fontFamily: 'Inter', fill: '#fff' }); const tableBorder = new fabric.Rect({ left: x+20, top: tableTop+22, width: 380, height: 120, fill: '#fff', stroke: '#e0e0e0', strokeWidth: 1 }); const totalBox = new fabric.Rect({ left: x+260, top: tableTop+155, width: 140, height: 60, fill: '#f5f5f5' }); const st = new fabric.Textbox('Sous-total HT', { left: x+270, top: tableTop+160, fontSize: 9, fontFamily: 'Inter' }); const stVal = new fabric.Textbox('0,00 €', { left: x+355, top: tableTop+160, fontSize: 9, fontWeight: '600', fontFamily: 'Inter' }); const tvaTxt = new fabric.Textbox('TVA 20%', { left: x+270, top: tableTop+178, fontSize: 9, fontFamily: 'Inter' }); const tvaVal2 = new fabric.Textbox('0,00 €', { left: x+355, top: tableTop+178, fontSize: 9, fontWeight: '600', fontFamily: 'Inter' }); const finalLine = new fabric.Line([x+270, tableTop+195, x+390, tableTop+195], { stroke: '#000', strokeWidth: 1 }); const finalLabel = new fabric.Textbox('TOTAL TTC', { left: x+270, top: tableTop+200, fontSize: 11, fontWeight: '700', fontFamily: 'Inter' }); const finalVal = new fabric.Textbox('0,00 €', { left: x+345, top: tableTop+200, fontSize: 11, fontWeight: '700', fontFamily: 'Inter' }); objs.push(logo, logoText, company, invoiceTitle, invoiceNum, divider, clientBox, clientLabel, clientInfo, tableHeader2, th1, th2, th3, th4, tableBorder, totalBox, st, stVal, tvaTxt, tvaVal2, finalLine, finalLabel, finalVal); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Facture Élégante'); }}, // === CARTES DE VOEUX === { name: 'Voeux Minimal', key: 'wishes_minimal', build: (canvas, x, y) => { const objs = []; const bg = new fabric.Rect({ left: x, top: y, width: 300, height: 420, fill: '#f5f5f5' }); const topBorder = new fabric.Rect({ left: x, top: y, width: 300, height: 4, fill: '#0088ff' }); const year = new fabric.Textbox('2025', { left: x+30, top: y+60, fontSize: 72, fontWeight: '900', fontFamily: 'Bebas Neue', fill: '#000' }); const message1 = new fabric.Textbox('Meilleurs vœux', { left: x+30, top: y+150, fontSize: 28, fontWeight: '400', fontFamily: 'Playfair Display' }); const message2 = new fabric.Textbox('pour une année', { left: x+30, top: y+185, fontSize: 16, fontFamily: 'Inter', fill: '#666' }); const message3 = new fabric.Textbox('pleine de succès', { left: x+30, top: y+210, fontSize: 16, fontFamily: 'Inter', fill: '#666' }); const decorLine = new fabric.Line([x+30, y+245, x+120, y+245], { stroke: '#0088ff', strokeWidth: 2 }); const signature = new fabric.Textbox('Votre Nom\nVotre Société', { left: x+30, top: y+330, fontSize: 12, fontFamily: 'Inter', lineHeight: 1.6, fill: '#999' }); objs.push(bg, topBorder, year, message1, message2, message3, decorLine, signature); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Voeux Minimal'); }}, { name: 'Voeux Festif', key: 'wishes_festive', build: (canvas, x, y) => { const objs = []; const bgGradient = new fabric.Rect({ left: x, top: y, width: 300, height: 420, fill: '#1a1a1a' }); const star1 = new fabric.Circle({ left: x+50, top: y+40, radius: 3, fill: '#ffd700' }); const star2 = new fabric.Circle({ left: x+180, top: y+80, radius: 2, fill: '#ffd700' }); const star3 = new fabric.Circle({ left: x+220, top: y+120, radius: 3, fill: '#ffd700' }); const star4 = new fabric.Circle({ left: x+90, top: y+160, radius: 2, fill: '#ffd700' }); const mainText = new fabric.Textbox('BONNE\nANNÉE', { left: x+50, top: y+180, width: 200, fontSize: 48, fontWeight: '900', fontFamily: 'Bebas Neue', fill: '#ffd700', textAlign: 'center', lineHeight: 0.9 }); const yearText = new fabric.Textbox('2 0 2 5', { left: x+50, top: y+280, width: 200, fontSize: 36, fontWeight: '400', fontFamily: 'Inter', fill: '#fff', textAlign: 'center', letterSpacing: 200 }); const subText = new fabric.Textbox('Que cette nouvelle année vous apporte\njoie, santé et réussite', { left: x+30, top: y+350, width: 240, fontSize: 9, fontFamily: 'Inter', fill: '#ccc', textAlign: 'center', lineHeight: 1.6 }); objs.push(bgGradient, star1, star2, star3, star4, mainText, yearText, subText); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Voeux Festif'); }}, // === TEMPLATES SUPPLÉMENTAIRES === { name: 'Présentation Projet', key: 'project_presentation', build: (canvas, x, y) => { const objs = []; const header = new fabric.Rect({ left: x, top: y, width: 400, height: 100, fill: '#000' }); const projectTitle = new fabric.Textbox('NOM DU PROJET', { left: x+30, top: y+25, fontSize: 32, fontWeight: '900', fontFamily: 'Poppins', fill: '#fff' }); const subtitle = new fabric.Textbox('Présentation client — Octobre 2025', { left: x+30, top: y+65, fontSize: 11, fontFamily: 'Inter', fill: '#999' }); const sectionTitle = new fabric.Textbox('CONTEXTE', { left: x+30, top: y+125, fontSize: 14, fontWeight: '700', fontFamily: 'Inter', letterSpacing: 100 }); const contextText = new fabric.Textbox('Description du contexte du projet, objectifs principaux et enjeux stratégiques pour le client.', { left: x+30, top: y+150, width: 340, fontSize: 10, fontFamily: 'Inter', lineHeight: 1.6 }); const imgPlaceholder = new fabric.Rect({ left: x+30, top: y+210, width: 340, height: 180, fill: '#f5f5f5', stroke: '#ddd', strokeWidth: 1, strokeDashArray: [5, 5] }); const imgLabel = new fabric.Textbox('[Image]', { left: x+180, top: y+290, fontSize: 12, fontFamily: 'Inter', fill: '#999' }); objs.push(header, projectTitle, subtitle, sectionTitle, contextText, imgPlaceholder, imgLabel); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Présentation Projet'); }}, { name: 'Flyer Événement', key: 'event_flyer', build: (canvas, x, y) => { const objs = []; const bg = new fabric.Rect({ left: x, top: y, width: 300, height: 420, fill: '#0088ff' }); const topShape = new fabric.Circle({ left: x+150, top: y-50, radius: 120, fill: '#0066cc', opacity: 0.3 }); const event = new fabric.Textbox('ÉVÉNEMENT\nSPÉCIAL', { left: x+30, top: y+80, width: 240, fontSize: 42, fontWeight: '900', fontFamily: 'Bebas Neue', fill: '#fff', lineHeight: 0.9 }); const date = new fabric.Textbox('19 OCTOBRE 2025', { left: x+30, top: y+190, fontSize: 14, fontWeight: '700', fontFamily: 'Inter', fill: '#fff', letterSpacing: 80 }); const time = new fabric.Textbox('19:00 - 23:00', { left: x+30, top: y+215, fontSize: 18, fontWeight: '300', fontFamily: 'Inter', fill: '#fff' }); const location = new fabric.Textbox('Lieu de l\'événement\nAdresse complète\nParis', { left: x+30, top: y+255, fontSize: 11, fontFamily: 'Inter', fill: '#fff', lineHeight: 1.6 }); const dividerLine = new fabric.Line([x+30, y+315, x+270, y+315], { stroke: '#fff', strokeWidth: 1, opacity: 0.5 }); const info = new fabric.Textbox('Inscription gratuite sur\nwww.evenement.com', { left: x+30, top: y+330, fontSize: 10, fontFamily: 'Inter', fill: '#fff', lineHeight: 1.6, textAlign: 'center', width: 240 }); objs.push(bg, topShape, event, date, time, location, dividerLine, info); const group = new fabric.Group(objs); canvas.add(group); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Flyer Événement'); }} ]; // Initialisation de la trappe document.addEventListener('DOMContentLoaded', function() { const toggleBtn = document.getElementById('toggleTrappe'); const trappePanel = document.getElementById('trappePanel'); const trappeContent = document.getElementById('trappeContent'); const designScroll = document.getElementById('designScroll'); const tabsColumn = document.getElementById('trappeTabs'); const scrollLeftBtn = document.getElementById('assetsScrollLeft'); const scrollRightBtn = document.getElementById('assetsScrollRight'); const groupBtn = document.getElementById('groupSelection'); const ungroupBtn = document.getElementById('ungroupSelection'); function updateGroupButtonsState() { const canvas = getActiveCanvas(); if (!canvas) return; const sel = canvas.getActiveObject(); const canGroup = (() => { if (!sel) return false; if (sel.type === 'activeSelection') { return sel._objects && sel._objects.length > 1; } return false; })(); const canUngroup = (() => sel && (sel.type === 'group' || sel.type === 'Group'))(); if (groupBtn) groupBtn.disabled = !canGroup; if (ungroupBtn) ungroupBtn.disabled = !canUngroup; } // Update state on selection changes canvases.forEach(c => { c.on('selection:created', () => { updateGroupButtonsState(); updateTextFrameUI(); }); c.on('selection:updated', () => { updateGroupButtonsState(); updateTextFrameUI(); }); c.on('selection:cleared', () => { updateGroupButtonsState(); updateTextFrameUI(); }); }); // Group button logic if (groupBtn) { groupBtn.addEventListener('click', () => { const canvas = getActiveCanvas(); if (!canvas) return; const sel = canvas.getActiveObject(); if (sel && sel.type === 'activeSelection' && sel._objects && sel._objects.length > 1) { const group = sel.toGroup(); canvas.setActiveObject(group); canvas.requestRenderAll(); updateLayersPanel(); saveState('Grouper'); updateGroupButtonsState(); } }); } // Ungroup button logic if (ungroupBtn) { ungroupBtn.addEventListener('click', () => { const canvas = getActiveCanvas(); if (!canvas) return; const sel = canvas.getActiveObject(); if (!sel) return; // Handle Group if (sel.type === 'group' || sel.type === 'Group') { const items = sel._objects.slice(); sel._restoreObjectsState(); canvas.remove(sel); items.forEach(o => canvas.add(o)); canvas.discardActiveObject(); canvas.requestRenderAll(); updateLayersPanel(); saveState('Dégrouper'); updateGroupButtonsState(); return; } // Handle ActiveSelection if (sel.type === 'activeSelection' || sel.type === 'ActiveSelection') { sel.toGroup(); // first make it a group, then ungroup const group = canvas.getActiveObject(); if (group && (group.type === 'group' || group.type === 'Group')) { const items = group._objects.slice(); group._restoreObjectsState(); canvas.remove(group); items.forEach(o => canvas.add(o)); canvas.discardActiveObject(); canvas.requestRenderAll(); updateLayersPanel(); saveState('Dégrouper sélection'); updateGroupButtonsState(); } } }); } // Initialize group buttons state on load updateGroupButtonsState(); const closeBtn = null; if (!toggleBtn || !trappePanel) return; // Fonction pour fermer la trappe const closeTrappe = () => { if (!trappePanel || trappePanel.style.display === 'none') return; // Start fade-out trappePanel.setAttribute('data-open', 'false'); // Remove listeners after transition const after = () => { trappePanel.style.display = 'none'; trappePanel.removeEventListener('transitionend', after); }; // Fallback timeout if transitionend doesn't fire setTimeout(after, 200); trappePanel.addEventListener('transitionend', after); }; // Toggle panneau toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = trappePanel.style.display !== 'none'; if (isVisible) { // fade-out then hide closeTrappe(); } else { // show and fade-in trappePanel.style.display = 'block'; // force reflow to apply transition void trappeContent.offsetHeight; trappePanel.setAttribute('data-open', 'true'); // Gérer le click extérieur pour fermer quand ouvert const outsideHandler = (ev) => { // ignorer si click dans le panneau ou sur le bouton toggle if (trappePanel.contains(ev.target) || toggleBtn.contains(ev.target)) return; closeTrappe(); document.removeEventListener('click', outsideHandler, true); document.removeEventListener('keydown', escHandler, true); }; const escHandler = (ev) => { if (ev.key === 'Escape') { closeTrappe(); document.removeEventListener('click', outsideHandler, true); document.removeEventListener('keydown', escHandler, true); } }; // différer pour ne pas capturer le click d'ouverture setTimeout(() => document.addEventListener('click', outsideHandler, true), 0); setTimeout(() => document.addEventListener('keydown', escHandler, true), 0); } }); // Bouton fermeture // closeBtn supprimé; fermeture gérée par clic extérieur // Keep tabs on the left (default order) - no override needed // Scroll buttons behavior function smoothScrollBy(delta) { if (!designScroll) return; designScroll.scrollBy({ left: delta, behavior: 'smooth' }); } if (scrollLeftBtn) { scrollLeftBtn.addEventListener('click', () => smoothScrollBy(-400)); } if (scrollRightBtn) { scrollRightBtn.addEventListener('click', () => smoothScrollBy(400)); } // Gestion des onglets document.querySelectorAll('.trappe-tab').forEach(tab => { tab.addEventListener('click', () => { const targetTab = tab.dataset.tab; // Activer l'onglet document.querySelectorAll('.trappe-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); // Afficher le contenu correspondant document.querySelectorAll('.trappe-content').forEach(content => { content.style.display = content.dataset.content === targetTab ? 'block' : 'none'; }); }); }); // Générer les items typo const typoContent = document.querySelector('[data-content="typo"]'); if (typoContent) { typographyPatterns.forEach((pattern, index) => { const item = document.createElement('div'); item.className = 'trappe-item'; item.draggable = true; item.dataset.patternIndex = index; item.dataset.patternType = 'typo'; const preview = document.createElement('div'); preview.className = 'trappe-item-preview'; preview.style.fontSize = pattern.objects[0].fontSize ? `${Math.min(pattern.objects[0].fontSize / 3, 16)}px` : '12px'; preview.style.fontWeight = pattern.objects[0].fontWeight || 'normal'; preview.textContent = pattern.objects[0].text.substring(0, 20); const label = document.createElement('div'); label.className = 'trappe-item-label'; label.textContent = pattern.name; item.appendChild(preview); item.appendChild(label); typoContent.appendChild(item); // Drag start item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('trappe-pattern', JSON.stringify({ type: 'typo', index: index, pattern: pattern })); }); }); } // Générer les items images const imgContent = document.querySelector('[data-content="img"]'); if (imgContent) { imageExamples.forEach((img, index) => { const item = document.createElement('div'); item.className = 'trappe-item'; item.draggable = true; item.dataset.imageIndex = index; item.dataset.patternType = 'img'; const preview = document.createElement('div'); preview.className = 'trappe-item-preview trappe-item-preview-img'; const imgEl = document.createElement('img'); imgEl.src = img.url; imgEl.style.maxWidth = '100%'; imgEl.style.maxHeight = '100%'; preview.appendChild(imgEl); item.appendChild(preview); imgContent.appendChild(item); // Drag start item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('trappe-pattern', JSON.stringify({ type: 'img', index: index, url: img.url })); }); }); } // Générer les items vecteurs const vectoContent = document.querySelector('[data-content="vecto"]'); if (vectoContent) { vectorExamples.forEach((vecto, index) => { const item = document.createElement('div'); item.className = 'trappe-item'; item.draggable = true; item.dataset.vectoIndex = index; item.dataset.patternType = 'vecto'; const preview = document.createElement('div'); preview.className = 'trappe-item-preview'; // Créer un aperçu visuel simple const shape = document.createElement('div'); shape.style.width = '40px'; shape.style.height = '40px'; shape.style.background = vecto.fill || 'transparent'; shape.style.display = 'flex'; shape.style.alignItems = 'center'; shape.style.justifyContent = 'center'; if (vecto.type === 'circle') { shape.style.borderRadius = '50%'; } else if (vecto.type === 'star') { shape.textContent = '⭐'; shape.style.background = 'transparent'; shape.style.fontSize = '28px'; shape.style.lineHeight = '40px'; } else if (vecto.type === 'triangle') { shape.style.width = '0'; shape.style.height = '0'; shape.style.borderLeft = '20px solid transparent'; shape.style.borderRight = '20px solid transparent'; shape.style.borderBottom = `40px solid ${vecto.fill || '#333'}`; shape.style.background = 'transparent'; } else if (vecto.type === 'polygon') { shape.textContent = '▲'; shape.style.color = vecto.fill || '#333'; shape.style.background = 'transparent'; shape.style.fontSize = '28px'; shape.style.lineHeight = '40px'; } else if (vecto.type === 'path') { // Aperçu simple par symbole shape.textContent = vecto.name === 'Close' ? '✕' : (vecto.name === 'Check' ? '✔' : '◎'); shape.style.color = (vecto.stroke || vecto.fill || '#333'); shape.style.background = 'transparent'; shape.style.fontSize = '20px'; shape.style.lineHeight = '40px'; } else if (vecto.type === 'group') { // Icône générique shape.textContent = vecto.name === 'User' ? '👤' : '☰'; shape.style.background = 'transparent'; shape.style.fontSize = '20px'; shape.style.lineHeight = '40px'; } preview.appendChild(shape); const label = document.createElement('div'); label.className = 'trappe-item-label'; label.textContent = vecto.name; item.appendChild(preview); item.appendChild(label); vectoContent.appendChild(item); // Drag start item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('trappe-pattern', JSON.stringify({ type: 'vecto', index: index, vector: vecto })); }); }); } // Générer les items de Layout const compContent = document.querySelector('[data-content="comp"]'); if (compContent) { compositionExamples.forEach((comp, index) => { const item = document.createElement('div'); item.className = 'trappe-item'; item.draggable = true; item.dataset.compIndex = index; item.dataset.patternType = 'comp'; const preview = document.createElement('div'); preview.className = 'trappe-item-preview'; preview.style.fontSize = '11px'; preview.textContent = comp.name; const label = document.createElement('div'); label.className = 'trappe-item-label'; label.textContent = comp.name; item.appendChild(preview); item.appendChild(label); compContent.appendChild(item); // Drag start: pack a light payload item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('trappe-pattern', JSON.stringify({ type: 'comp', index })); }); }); } }); // Initialiser le drag & drop sur toute la zone de canvas après le chargement window.addEventListener('load', () => { init(); // Initialiser l'écouteur de scroll après l'initialisation setTimeout(initScrollListener, 500); // Initialiser le drag & drop après un délai pour s'assurer que tout est prêt setTimeout(() => { const canvasScrollArea = document.getElementById('canvasScrollArea'); if (!canvasScrollArea) return; canvasScrollArea.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); canvasScrollArea.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); // Files from OS have priority if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { const imgFile = Array.from(e.dataTransfer.files).find(f => (f.type || '').startsWith('image/')); if (imgFile) { handleDroppedImageFile(imgFile, e.clientX, e.clientY); return; } } const data = e.dataTransfer.getData('trappe-pattern'); if (!data) return; try { const pattern = JSON.parse(data); // Trouver le canvas sous la souris let targetCanvas = null; const canvasContainers = document.querySelectorAll('.canvas-container'); for (const container of canvasContainers) { const canvasEl = container.querySelector('canvas'); if (!canvasEl) continue; const rect = canvasEl.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { const idMatch = canvasEl.id && canvasEl.id.match(/^canvas-(\d+)$/); const pageIndex = idMatch ? parseInt(idMatch[1], 10) : NaN; if (!isNaN(pageIndex) && canvases[pageIndex]) { targetCanvas = canvases[pageIndex]; // Calculer la position relative au canvas en tenant compte de l'échelle const scaleX = targetCanvas.getWidth() / rect.width; const scaleY = targetCanvas.getHeight() / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; // Déposer l'objet sur ce canvas dropPatternOnCanvas(pattern, targetCanvas, x, y); break; } } } if (!targetCanvas) { // Fallback: utiliser le canvas actif const activeCanvas = getActiveCanvas(); if (activeCanvas) { const pointer = activeCanvas.getPointer(e); dropPatternOnCanvas(pattern, activeCanvas, pointer.x, pointer.y); } } } catch (error) { console.error('Erreur lors du drop:', error); } }); // Fallback global pour fiabiliser le drop (au cas où l'événement n'atteint pas canvasScrollArea) document.addEventListener('dragover', (e) => { const types = e.dataTransfer && e.dataTransfer.types ? Array.from(e.dataTransfer.types) : []; if (types.includes('trappe-pattern') || types.includes('text/plain')) { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } }, true); document.addEventListener('drop', (e) => { const types = e.dataTransfer && e.dataTransfer.types ? Array.from(e.dataTransfer.types) : []; // Native files first if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { const imgFile = Array.from(e.dataTransfer.files).find(f => (f.type || '').startsWith('image/')); if (imgFile) { e.preventDefault(); e.stopPropagation(); handleDroppedImageFile(imgFile, e.clientX, e.clientY); return; } } if (!(types.includes('trappe-pattern') || types.includes('text/plain'))) return; e.preventDefault(); e.stopPropagation(); const raw = (e.dataTransfer.getData('trappe-pattern') || e.dataTransfer.getData('text/plain')); if (!raw) return; try { const pattern = JSON.parse(raw); // Scanner les canvases visibles const canvasEls = document.querySelectorAll('#pagesContainer canvas'); for (const canvasEl of canvasEls) { const rect = canvasEl.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { const idMatch = canvasEl.id && canvasEl.id.match(/^canvas-(\d+)$/); const pageIndex = idMatch ? parseInt(idMatch[1], 10) : NaN; if (!isNaN(pageIndex) && canvases[pageIndex]) { const targetCanvas2 = canvases[pageIndex]; const scaleX = targetCanvas2.getWidth() / rect.width; const scaleY = targetCanvas2.getHeight() / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; dropPatternOnCanvas(pattern, targetCanvas2, x, y); return; } } } // Dernier recours const activeCanvas = getActiveCanvas(); if (activeCanvas) { const pointer = activeCanvas.getPointer(e); dropPatternOnCanvas(pattern, activeCanvas, pointer.x, pointer.y); } } catch (err) { console.error('Erreur lors du drop (global):', err); } }, true); }, 1000); }); // Handle dropped OS image file: insert into selected shape or as image at drop point function handleDroppedImageFile(file, clientX, clientY) { const reader = new FileReader(); reader.onload = (ev) => { const dataUrl = ev.target.result; // Find target canvas at pointer const canvasEls = document.querySelectorAll('#pagesContainer canvas'); let targetCanvas = null; let rect = null; for (const canvasEl of canvasEls) { const r = canvasEl.getBoundingClientRect(); if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) { const idMatch = canvasEl.id && canvasEl.id.match(/^canvas-(\d+)$/); const pageIndex = idMatch ? parseInt(idMatch[1], 10) : NaN; if (!isNaN(pageIndex) && canvases[pageIndex]) { targetCanvas = canvases[pageIndex]; rect = r; break; } } } if (!targetCanvas) targetCanvas = getActiveCanvas(); if (!targetCanvas) return; // Compute coordinates in canvas space const targetRect = rect || targetCanvas.lowerCanvasEl.getBoundingClientRect(); const scaleX = targetCanvas.getWidth() / targetRect.width; const scaleY = targetCanvas.getHeight() / targetRect.height; const x = (clientX - targetRect.left) * scaleX; const y = (clientY - targetRect.top) * scaleY; // If dropping over a supported shape, mask into it const supported = ['rect','circle','ellipse','polygon','path']; let shapeUnderPointer = null; const pointer = new fabric.Point(x, y); const objects = targetCanvas.getObjects().slice().reverse(); for (const o of objects) { if (supported.includes(o.type) && o.containsPoint && o.containsPoint(pointer)) { shapeUnderPointer = o; break; } } const activeObj = targetCanvas.getActiveObject(); const targetShape = (activeObj && supported.includes(activeObj.type)) ? activeObj : shapeUnderPointer; if (targetShape) { fabric.Image.fromURL(dataUrl, (img) => { applyImageMaskToShape(targetShape, img, targetCanvas, dataUrl); }, { crossOrigin: 'anonymous' }); } else { fabric.Image.fromURL(dataUrl, (img) => { img.set({ left: x, top: y }); targetCanvas.add(img); targetCanvas.setActiveObject(img); targetCanvas.requestRenderAll(); updateLayersPanel(); saveState('Image ajoutée'); }, { crossOrigin: 'anonymous' }); } }; reader.readAsDataURL(file); } // Fonction helper pour déposer un pattern sur un canvas function dropPatternOnCanvas(pattern, canvas, x, y) { if (pattern.type === 'typo') { // Créer les objets typo const objects = []; for (const obj of pattern.pattern.objects) { const text = new fabric.Textbox(obj.text, { left: x + (obj.left || 0), top: y + (obj.top || 0), fontSize: obj.fontSize || 12, fontFamily: obj.fontFamily || 'Inter', fontWeight: obj.fontWeight || 'normal', fontStyle: obj.fontStyle || 'normal', fill: obj.fill || '#000000', textAlign: obj.textAlign || 'left', width: obj.width || 200 }); canvas.add(text); objects.push(text); } // Grouper si plusieurs objets if (objects.length > 1) { const group = new fabric.Group(objects); canvas.remove(...objects); canvas.add(group); canvas.setActiveObject(group); } else if (objects.length === 1) { canvas.setActiveObject(objects[0]); } canvas.renderAll(); updateLayersPanel(); saveState('Pattern typo ajouté'); } else if (pattern.type === 'img') { // Ajouter l'image fabric.Image.fromURL(pattern.url, (img) => { img.set({ left: x, top: y, scaleX: 0.5, scaleY: 0.5 }); canvas.add(img); canvas.setActiveObject(img); canvas.renderAll(); updateLayersPanel(); saveState('Image ajoutée'); }); } else if (pattern.type === 'vecto') { // Ajouter le vecteur const vecto = pattern.vector; let shape; if (vecto.type === 'rect') { shape = new fabric.Rect({ left: x, top: y, width: vecto.width, height: vecto.height, fill: vecto.fill }); } else if (vecto.type === 'ellipse') { shape = new fabric.Ellipse({ left: x, top: y, rx: vecto.rx || ((vecto.width||80)/2), ry: vecto.ry || ((vecto.height||60)/2), fill: vecto.fill || '#666' }); } else if (vecto.type === 'circle') { shape = new fabric.Circle({ left: x, top: y, radius: vecto.radius, fill: vecto.fill }); } else if (vecto.type === 'star') { const points = []; const spikes = vecto.points || 5; const outerRadius = vecto.radius; const innerRadius = outerRadius / 2; for (let i = 0; i < spikes * 2; i++) { const radius = i % 2 === 0 ? outerRadius : innerRadius; const angle = (i * Math.PI) / spikes; points.push({ x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }); } shape = new fabric.Polygon(points, { left: x, top: y, fill: vecto.fill }); } else if (vecto.type === 'triangle') { shape = new fabric.Triangle({ left: x, top: y, width: vecto.width, height: vecto.height, fill: vecto.fill }); } else if (vecto.type === 'polygon' && Array.isArray(vecto.points)) { // Construire un polygon depuis une liste de points const points = vecto.points.map(p => ({ x: p.x, y: p.y })); shape = new fabric.Polygon(points, { left: x, top: y, fill: vecto.fill || '#333' }); } else if (vecto.type === 'path' && typeof vecto.path === 'string') { // Créer un objet path shape = new fabric.Path(vecto.path, { left: x, top: y, fill: vecto.fill || '#333', stroke: vecto.stroke || null, strokeWidth: vecto.strokeWidth || 0, scaleX: vecto.scale || 1, scaleY: vecto.scale || 1 }); } else if (vecto.type === 'group' && Array.isArray(vecto.items)) { // Construire un groupe simple d'items de base const items = vecto.items.map(it => { if (it.type === 'circle') { return new fabric.Circle({ left: (it.left||0) + x, top: (it.top||0) + y, radius: it.radius||10, fill: it.fill||'#333' }); } else if (it.type === 'rect') { return new fabric.Rect({ left: (it.left||0) + x, top: (it.top||0) + y, width: it.width||20, height: it.height||10, rx: it.rx||0, ry: it.ry||0, fill: it.fill||'#333' }); } return null; }).filter(Boolean); if (items.length === 1) { shape = items[0]; } else if (items.length > 1) { shape = new fabric.Group(items); } } if (shape) { canvas.add(shape); canvas.setActiveObject(shape); canvas.renderAll(); updateLayersPanel(); saveState('Forme ajoutée'); } } else if (pattern.type === 'comp') { // Ajouter un layout (CV, Devis, Facture) const comp = compositionExamples && compositionExamples[pattern.index]; if (comp && typeof comp.build === 'function') { comp.build(canvas, x, y); } } } // ======================================== // 📄 IMPORT PDF // ======================================== let currentPDFDocument = null; let currentPDFFile = null; let selectedPDFPages = []; // Pages sélectionnées visuellement // Bouton Import principal (toolbar) - ouvre le sélecteur PDF document.getElementById('openImportModal')?.addEventListener('click', () => { document.getElementById('pdfFileInput').click(); }); // Ouvrir le sélecteur de fichier PDF (depuis bouton dédié si visible) document.getElementById('importPDF')?.addEventListener('click', () => { document.getElementById('pdfFileInput').click(); }); // Gérer la sélection de fichier PDF document.getElementById('pdfFileInput')?.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; if (!file.type.includes('pdf')) { alert('⚠️ Veuillez sélectionner un fichier PDF valide.'); return; } currentPDFFile = file; await showPDFImportModal(file); }); // Afficher la modal d'import PDF avec prévisualisation async function showPDFImportModal(file) { const modal = document.getElementById('pdfImportModal'); const loadingMsg = document.getElementById('pdfLoadingMessage'); const selectionContent = document.getElementById('pdfSelectionContent'); if (!modal || !loadingMsg || !selectionContent) { console.error('❌ Éléments de la modale PDF introuvables'); return; } modal.style.display = 'flex'; loadingMsg.style.display = 'block'; selectionContent.style.display = 'none'; try { // Vérifier que PDF.js est chargé if (typeof pdfjsLib === 'undefined') { throw new Error('PDF.js n\'est pas chargé. Rechargez la page.'); } // Charger le PDF avec PDF.js const arrayBuffer = await file.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); currentPDFDocument = await loadingTask.promise; // Mettre à jour l'interface const fileNameEl = document.getElementById('pdfFileName'); const pageCountEl = document.getElementById('pdfPageCount'); if (fileNameEl) fileNameEl.textContent = file.name; if (pageCountEl) pageCountEl.textContent = currentPDFDocument.numPages; // Générer les miniatures de toutes les pages await generatePDFThumbnails(currentPDFDocument); loadingMsg.style.display = 'none'; selectionContent.style.display = 'block'; } catch (error) { console.error('Erreur lors du chargement du PDF:', error); alert('❌ Erreur lors du chargement du PDF.\n\n' + error.message); closePDFImportModal(); } } // Générer les miniatures des pages PDF async function generatePDFThumbnails(pdfDoc) { const gallery = document.getElementById('pdfPagesGallery'); if (!gallery) { console.error('❌ Élément pdfPagesGallery introuvable'); return; } gallery.innerHTML = ''; selectedPDFPages = []; // Réinitialiser la sélection for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) { try { const page = await pdfDoc.getPage(pageNum); const viewport = page.getViewport({ scale: 0.5 }); // Échelle pour miniature // Créer un canvas pour la miniature const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; // Rendre la page await page.render({ canvasContext: context, viewport: viewport }).promise; // Créer le conteneur de miniature const thumbnailDiv = document.createElement('div'); thumbnailDiv.className = 'pdf-page-thumbnail'; thumbnailDiv.dataset.pageNum = pageNum; // Numéro de page const pageLabel = document.createElement('div'); pageLabel.className = 'pdf-page-number'; pageLabel.textContent = `${pageNum} de ${pdfDoc.numPages}`; thumbnailDiv.appendChild(pageLabel); // Ajouter le canvas thumbnailDiv.appendChild(canvas); // Gestion du clic pour sélection (Ctrl/Cmd pour multi-sélection) thumbnailDiv.addEventListener('click', (e) => { const isMultiSelect = e.ctrlKey || e.metaKey; if (!isMultiSelect) { // Sélection simple : désélectionner toutes les autres document.querySelectorAll('.pdf-page-thumbnail').forEach(thumb => { thumb.classList.remove('selected'); }); selectedPDFPages = []; } // Toggle la sélection de cette page if (thumbnailDiv.classList.contains('selected')) { thumbnailDiv.classList.remove('selected'); selectedPDFPages = selectedPDFPages.filter(p => p !== pageNum); } else { thumbnailDiv.classList.add('selected'); selectedPDFPages.push(pageNum); } // Mettre à jour le compteur updatePDFSelectionCount(); }); gallery.appendChild(thumbnailDiv); // Sélectionner automatiquement la première page if (pageNum === 1) { thumbnailDiv.classList.add('selected'); selectedPDFPages = [1]; } } catch (pageError) { console.error(`Erreur lors du rendu de la page ${pageNum}:`, pageError); } } } // Sélectionner toutes les pages PDF function selectAllPDFPages() { if (!currentPDFDocument) return; selectedPDFPages = []; document.querySelectorAll('.pdf-page-thumbnail').forEach(thumb => { thumb.classList.add('selected'); selectedPDFPages.push(parseInt(thumb.dataset.pageNum)); }); updatePDFSelectionCount(); } // Désélectionner toutes les pages PDF function deselectAllPDFPages() { selectedPDFPages = []; document.querySelectorAll('.pdf-page-thumbnail').forEach(thumb => { thumb.classList.remove('selected'); }); updatePDFSelectionCount(); } // Mettre à jour le compteur de sélection function updatePDFSelectionCount() { const counter = document.getElementById('pdfSelectionCount'); if (counter) { const count = selectedPDFPages.length; counter.textContent = count === 0 ? 'Aucune sélectionnée' : count === 1 ? '1 sélectionnée' : `${count} sélectionnées`; } } // Fermer la modal d'import PDF function closePDFImportModal() { const modal = document.getElementById('pdfImportModal'); if (modal) modal.style.display = 'none'; currentPDFDocument = null; currentPDFFile = null; selectedPDFPages = []; const fileInput = document.getElementById('pdfFileInput'); if (fileInput) fileInput.value = ''; // Nettoyer la galerie const gallery = document.getElementById('pdfPagesGallery'); if (gallery) gallery.innerHTML = ''; } // Confirmer l'import PDF async function confirmPDFImport() { if (!currentPDFDocument) { alert('❌ Aucun document PDF chargé.'); return; } // Utiliser les pages sélectionnées visuellement let pagesToImport = selectedPDFPages.length > 0 ? selectedPDFPages : [1]; if (pagesToImport.length === 0) { alert('⚠️ Veuillez sélectionner au moins une page.'); return; } try { // Récupérer le mode de recadrage const recadrageMode = document.getElementById('pdfRecadrageMode')?.value || 'support'; // Sauvegarder les références avant de fermer la modal const pdfDoc = currentPDFDocument; const pdfFile = currentPDFFile; // Fermer la modal AVANT l'import pour que l'utilisateur voit la progression closePDFImportModal(); // Importer les pages en passant le document PDF await importPDFPages(pagesToImport, recadrageMode, pdfDoc, pdfFile); } catch (error) { console.error('Erreur lors de l\'import PDF:', error); alert('❌ Erreur lors de l\'import du PDF.\n\n' + error.message); } } // Importer les pages PDF sélectionnées async function importPDFPages(pageNumbers, recadrageMode = 'support', pdfDoc = null, pdfFile = null) { // Utiliser le document passé en paramètre ou celui en mémoire const pdfDocument = pdfDoc || currentPDFDocument; const pdfFileName = pdfFile?.name || currentPDFFile?.name || 'PDF'; if (!pdfDocument) { alert('❌ Aucun document PDF disponible.'); return; } if (pageNumbers.length === 0) { alert('⚠️ Aucune page sélectionnée.'); return; } // Trier les numéros de page pageNumbers.sort((a, b) => a - b); try { // Si plusieurs pages : créer une nouvelle page SUPERPRINT pour chaque page PDF const importMultiplePages = pageNumbers.length > 1; for (let i = 0; i < pageNumbers.length; i++) { const pageNum = pageNumbers[i]; // Créer une nouvelle page SUPERPRINT si besoin (sauf pour la première) if (importMultiplePages && i > 0) { saveAllPages(); createNewPage(); currentPageIndex = pages.length - 1; renderAllPages(); updatePageIndicator(); } // Récupérer le canvas actuel const canvas = getActiveCanvas(); if (!canvas) { console.error(`❌ Impossible de récupérer le canvas pour la page ${i + 1}`); continue; } const page = await pdfDocument.getPage(pageNum); // Obtenir les dimensions de la page PDF const viewport = page.getViewport({ scale: 2.0 }); // Haute résolution // Créer un canvas temporaire pour rendre la page const tempCanvas = document.createElement('canvas'); const context = tempCanvas.getContext('2d'); tempCanvas.width = viewport.width; tempCanvas.height = viewport.height; // Rendre la page PDF await page.render({ canvasContext: context, viewport: viewport }).promise; // Convertir en image const imgDataUrl = tempCanvas.toDataURL('image/png'); // Créer l'image Fabric avec poignées de contrôle personnalisées fabric.Image.fromURL(imgDataUrl, (img) => { // Calculer la taille pour adapter à la page const scaleX = (pageFormat.width * 0.8) / img.width; const scaleY = (pageFormat.height * 0.8) / img.height; const scale = Math.min(scaleX, scaleY); img.set({ left: pageFormat.width / 2, top: pageFormat.height / 2, originX: 'center', originY: 'center', scaleX: scale, scaleY: scale, selectable: true, hasControls: true, hasBorders: true, lockRotation: false, // Données personnalisées pour identifier comme PDF importé isPDFImport: true, pdfPageNumber: pageNum, pdfFileName: pdfFileName }); // Activer toutes les poignées de contrôle pour le recadrage/redimensionnement img.setControlsVisibility({ mt: true, // Middle top mb: true, // Middle bottom ml: true, // Middle left mr: true, // Middle right tl: true, // Top left tr: true, // Top right bl: true, // Bottom left br: true, // Bottom right mtr: true // Middle top rotate }); // Personnaliser les poignées pour le recadrage visuel img.cornerColor = '#2196f3'; img.cornerSize = 12; img.transparentCorners = false; img.cornerStyle = 'circle'; img.borderColor = '#2196f3'; img.borderScaleFactor = 2; canvas.add(img); canvas.setActiveObject(img); canvas.requestRenderAll(); // Message de confirmation console.log(`✅ Page ${pageNum} du PDF importée avec succès`); }, { crossOrigin: 'anonymous' }); // Petit délai entre chaque page pour ne pas surcharger await new Promise(resolve => setTimeout(resolve, 100)); } // Sauvegarder l'état après import saveState('Import PDF'); // Message de confirmation const pageText = pageNumbers.length > 1 ? 'pages' : 'page'; alert(`✅ ${pageNumbers.length} ${pageText} PDF importée(s) avec succès!\n\n💡 Utilisez les poignées pour redimensionner et recadrer l'image.`); } catch (error) { console.error('Erreur lors de l\'import des pages PDF:', error); alert('❌ Erreur lors de l\'import des pages PDF.\n\n' + error.message); } } // Parser une plage de pages (ex: "1-3, 5, 7-9") function parsePageRange(rangeStr, maxPage) { const pages = new Set(); const parts = rangeStr.split(',').map(s => s.trim()); for (const part of parts) { if (part.includes('-')) { const [start, end] = part.split('-').map(n => parseInt(n.trim())); if (isNaN(start) || isNaN(end) || start < 1 || end > maxPage || start > end) { continue; } for (let i = start; i <= end; i++) { pages.add(i); } } else { const pageNum = parseInt(part); if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= maxPage) { pages.add(pageNum); } } } return Array.from(pages).sort((a, b) => a - b); } // Détecter le format d'un PDF à partir de ses dimensions function detectPDFFormat(widthMM, heightMM) { // Formats standard avec tolérance de ±2mm const tolerance = 2; const formats = [ { name: 'A4', width: 210, height: 297 }, { name: 'A3', width: 297, height: 420 }, { name: 'A5', width: 148, height: 210 }, { name: 'Letter', width: 215.9, height: 279.4 }, { name: 'Legal', width: 215.9, height: 355.6 }, { name: 'Tabloid', width: 279.4, height: 431.8 }, { name: 'B4', width: 250, height: 353 }, { name: 'B5', width: 176, height: 250 } ]; // Vérifier format portrait for (const format of formats) { if (Math.abs(widthMM - format.width) < tolerance && Math.abs(heightMM - format.height) < tolerance) { return { name: format.name + ' Portrait', width: format.width, height: format.height }; } } // Vérifier format paysage for (const format of formats) { if (Math.abs(widthMM - format.height) < tolerance && Math.abs(heightMM - format.width) < tolerance) { return { name: format.name + ' Paysage', width: format.height, height: format.width }; } } // Format personnalisé return { name: 'Format personnalisé', width: Math.round(widthMM), height: Math.round(heightMM) }; } // Importer les pages PDF sélectionnées async function importPDFPages(pageNumbers) { if (!currentPDFDocument || pageNumbers.length === 0) return; const progressMsg = document.createElement('div'); progressMsg.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.9); color: white; padding: 30px 40px; border-radius: 12px; z-index: 100000; font-size: 16px; text-align: center;'; progressMsg.innerHTML = '
📄
Import PDF en cours...
0 / ' + pageNumbers.length + '
'; document.body.appendChild(progressMsg); try { // 🎯 DÉTECTION DU FORMAT PDF - Récupérer les dimensions de la première page const firstPage = await currentPDFDocument.getPage(pageNumbers[0]); const firstViewport = firstPage.getViewport({ scale: 1.0 }); // Convertir les dimensions PDF (points) en mm (1 point = 0.352778 mm) const pdfWidthMM = firstViewport.width * 0.352778; const pdfHeightMM = firstViewport.height * 0.352778; const pdfOrientation = pdfWidthMM > pdfHeightMM ? 'landscape' : 'portrait'; // Sauvegarder le format actuel de SUPERPRINT const originalFormat = { ...pageFormat }; // Détecter le format PDF et ajuster SUPERPRINT let detectedFormat = detectPDFFormat(pdfWidthMM, pdfHeightMM); if (detectedFormat) { // Appliquer le format détecté if (confirm(`📄 Format PDF détecté: ${detectedFormat.name} (${Math.round(pdfWidthMM)}×${Math.round(pdfHeightMM)}mm)\n\n` + `✅ Voulez-vous ajuster le format de SUPERPRINT pour correspondre au PDF?\n\n` + `• OUI = Crée les pages au format du PDF\n` + `• NON = Garde le format actuel (${pageFormat.name})`)) { // Mettre à jour le format de page pageFormat = { name: detectedFormat.name, width: Math.round(pdfWidthMM), height: Math.round(pdfHeightMM), orientation: pdfOrientation }; // Mettre à jour l'interface const formatSelect = document.getElementById('pageFormat'); if (formatSelect) { formatSelect.value = 'custom'; } document.getElementById('customWidth').value = pageFormat.width; document.getElementById('customHeight').value = pageFormat.height; } } for (let i = 0; i < pageNumbers.length; i++) { const pageNum = pageNumbers[i]; document.getElementById('pdfProgressText').textContent = `${i + 1} / ${pageNumbers.length}`; // Créer une nouvelle page pour chaque page PDF addPageFromChemin('single'); await new Promise(resolve => setTimeout(resolve, 150)); // Attendre que la page soit créée // Rendre la page PDF en image const page = await currentPDFDocument.getPage(pageNum); const viewport = page.getViewport({ scale: 3.0 }); // Très haute résolution (300 DPI) const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: context, viewport: viewport }).promise; // Convertir en image et ajouter au canvas Fabric const imgDataUrl = canvas.toDataURL('image/png'); const activeCanvas = getActiveCanvas(); if (activeCanvas) { fabric.Image.fromURL(imgDataUrl, (img) => { // Ajuster la taille de l'image pour qu'elle remplisse la page const scaleX = pageFormat.width / img.width; const scaleY = pageFormat.height / img.height; const scale = Math.min(scaleX, scaleY); img.set({ left: (pageFormat.width - img.width * scale) / 2, top: (pageFormat.height - img.height * scale) / 2, scaleX: scale, scaleY: scale, selectable: true }); activeCanvas.add(img); activeCanvas.requestRenderAll(); }); } } await new Promise(resolve => setTimeout(resolve, 300)); } document.getElementById('pdfModal').classList.remove('active'); } catch (error) { console.error('Erreur lors de l\'import PDF:', error); alert('Erreur lors de l\'import du PDF'); document.getElementById('pdfModal').classList.remove('active'); } }