ajout d'export et import

suppression du bouton base64 pour l'image
correction de bug au niveau du texte et puce de numéro
This commit is contained in:
streaper2
2026-07-07 22:04:00 +02:00
parent 21b052ea59
commit 3c1f5c18b9
2 changed files with 440 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, Type, Image as ImageIcon, Layout, MousePointer2, Trash2, Code, AlignLeft, AlignCenter, AlignRight, Loader2, Link as LinkIcon, Smile, Minus, Heading, Bold, Italic, Underline, List, ListOrdered, Upload, Save, RotateCcw, FileX } from 'lucide-react';
import { Download, Type, Image as ImageIcon, Layout, MousePointer2, Trash2, Code, AlignLeft, AlignCenter, AlignRight, Loader2, Link as LinkIcon, Smile, Minus, Heading, Bold, Italic, Underline, List, ListOrdered, Upload, Save, RotateCcw, FileX, Palette } from 'lucide-react';
// --- CONFIGURATION & CONSTANTES ---
const DEFAULT_WIDTH = "600";
@@ -63,7 +63,7 @@ const BLOCK_TEMPLATES: Record<string, any> = {
bgColor: '#eeeeee',
backgroundImage: '',
url: '', // Lien de redirection
exportAsImage: true, // Nouveau: Détermine si l'en-tête est converti en Data Base64
exportAsImage: false, // Nouveau: Détermine si l'en-tête est converti en Data Base64
textColor: PURPLE_COLOR,
subtitleColor: ORANGE_COLOR,
padding: '30',
@@ -179,6 +179,7 @@ const BLOCK_TEMPLATES: Record<string, any> = {
// --- COMPOSANT MINI RTE (Rich Text Editor) ---
const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string) => void }) => {
const editorRef = useRef<HTMLDivElement>(null);
const savedSelectionRef = useRef<Range | null>(null);
const execCmd = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
@@ -187,6 +188,30 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
}
};
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (editorRef.current && editorRef.current.contains(range.commonAncestorContainer)) {
savedSelectionRef.current = range.cloneRange();
}
}
};
const handleColorChange = (color: string) => {
if (savedSelectionRef.current) {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(savedSelectionRef.current);
}
}
document.execCommand('foreColor', false, color);
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
};
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
if (document.activeElement !== editorRef.current) {
@@ -204,14 +229,29 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
<div className="w-px h-4 bg-gray-300 mx-1"></div>
<RteButton onClick={() => execCmd('insertUnorderedList')} icon={<List size={14} />} title="Liste à puces" />
<RteButton onClick={() => execCmd('insertOrderedList')} icon={<ListOrdered size={14} />} title="Liste numérotée" />
<div className="w-px h-4 bg-gray-300 mx-1"></div>
<Tooltip text="Couleur du texte sélectionné" className="w-auto">
<div className="flex items-center gap-1 p-1 hover:bg-gray-200 rounded cursor-pointer">
<Palette size={14} className="text-gray-700" />
<input
type="color"
onChange={(e) => handleColorChange(e.target.value)}
onFocus={saveSelection}
onMouseDown={saveSelection}
className="w-4 h-4 p-0 border-0 cursor-pointer bg-transparent"
/>
</div>
</Tooltip>
</div>
<div
ref={editorRef}
contentEditable
className="p-3 min-h-[150px] text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-200"
onInput={(e) => onChange(e.currentTarget.innerHTML)}
onBlur={(e) => onChange(e.currentTarget.innerHTML)}
className="wysiwyg p-3 min-h-[150px] text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-200"
onInput={(e) => { saveSelection(); onChange(e.currentTarget.innerHTML); }}
onBlur={(e) => { saveSelection(); onChange(e.currentTarget.innerHTML); }}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}
/>
</div>
@@ -236,6 +276,7 @@ export default function NewsletterBuilder() {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [globalBg, setGlobalBg] = useState('#f3f4f6');
const [isExporting, setIsExporting] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const hasHeader = blocks.some(b => b.type === 'header');
const hasFooter = blocks.some(b => b.type === 'footer');
@@ -485,7 +526,7 @@ export default function NewsletterBuilder() {
});
};
const generateHTML = (blocksToRender: any[]) => {
const generateHTML = (blocksToRender: any[], originalBlocks?: any[]) => {
const renderBlockHTML = (block: any) => {
const fontFamily = block.fontFamily || FONT_OPTIONS.sans.value;
const commonStyle = `font-family: ${fontFamily}; box-sizing: border-box;`;
@@ -584,6 +625,7 @@ export default function NewsletterBuilder() {
}
};
const builderMetadata = originalBlocks ? JSON.stringify({ blocks: originalBlocks, globalBg }) : '';
const rows = blocksToRender.map(renderBlockHTML).join('');
return `<!DOCTYPE html>
@@ -605,6 +647,7 @@ p { margin-top: 0; margin-bottom: 10px; }
${rows}
</table>
</td></tr></table>
<!-- NEWSLETTER_BUILDER_DATA: ${builderMetadata} -->
</body></html>`;
};
@@ -631,7 +674,7 @@ p { margin-top: 0; margin-bottom: 10px; }
}
return block;
}));
const html = generateHTML(processedBlocks);
const html = generateHTML(processedBlocks, includeMetadata ? blocks : undefined);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -644,6 +687,338 @@ p { margin-top: 0; margin-bottom: 10px; }
setIsExporting(false);
};
const importHTML = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
if (!text) return;
let parsedData: any = null;
// 1. Essai de lecture du commentaire HTML (Reconstruction parfaite)
const commentMatch = text.match(/<!-- NEWSLETTER_BUILDER_DATA:\s*({.*?})\s*-->/s);
if (commentMatch) {
try {
parsedData = JSON.parse(commentMatch[1]);
} catch (err) {
console.warn("Échec parsing commentaire JSON", err);
}
}
// 2. Fallback : Analyse du DOM HTML pur (Reconstruction par structure)
if (!parsedData) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const bodyBg = doc.body.style.backgroundColor || "#f3f4f6";
const innerTable = doc.querySelector('table table');
if (innerTable) {
const rows = Array.from(innerTable.querySelectorAll('tr'));
const blocksList: any[] = [];
for (let i = 0; i < rows.length; i++) {
const tr = rows[i];
// Ignorer les lignes imbriquées dans des sous-tables
const parentTable = tr.parentElement?.closest('table');
if (parentTable && parentTable !== innerTable) {
continue;
}
const td = tr.querySelector('td');
if (!td) continue;
const bgColor = td.style.backgroundColor || "#ffffff";
let padding = "10";
if (td.style.padding) {
padding = td.style.padding.replace("px", "").split(" ")[0];
}
const align = td.getAttribute("align") || "left";
// A. Séparateur / Divider
const dividerDiv = td.querySelector('div[style*="border-top"]');
if (dividerDiv) {
const styleStr = dividerDiv.getAttribute("style") || "";
const thicknessMatch = styleStr.match(/border-top:\s*(\d+)px/);
const styleMatch = styleStr.match(/border-top:\s*\d+px\s+(\w+)/);
const colorMatch = styleStr.match(/border-top:\s*\d+px\s+\w+\s+(#[a-fA-F0-9]+|rgb.*|hsl.*)/);
blocksList.push({
id: `imported-divider-${Date.now()}-${i}`,
type: 'divider',
lineStyle: styleMatch ? styleMatch[1] : 'solid',
lineColor: colorMatch ? colorMatch[1] : '#800080',
thickness: thicknessMatch ? thicknessMatch[1] : '2',
bgColor,
padding
});
continue;
}
// B. Bouton
const btnTable = td.querySelector('table');
if (btnTable) {
const btnLink = btnTable.querySelector('a');
if (btnLink) {
const url = btnLink.getAttribute("href") || "#";
const btnColor = btnTable.querySelector('td')?.getAttribute("bgcolor") || "#f37037";
const textColor = btnLink.style.color || "#ffffff";
const borderRadius = btnTable.querySelector('td')?.style.borderRadius?.replace("px", "") || "4";
blocksList.push({
id: `imported-button-${Date.now()}-${i}`,
type: 'button',
content: btnLink.textContent?.trim() || 'BOUTON',
url,
btnColor,
textColor,
bgColor,
padding,
align,
borderRadius,
width: btnLink.style.display === 'block' ? 'full' : 'auto',
fontFamily: btnLink.style.fontFamily || 'Helvetica, Arial, sans-serif'
});
continue;
}
}
// C. Lien simple
const simpleLink = td.querySelector('a');
const h2Tag = td.querySelector('h2');
const h1Tag = td.querySelector('h1');
if (simpleLink && !h2Tag && !h1Tag) {
const imgInside = simpleLink.querySelector('img');
if (imgInside) {
blocksList.push({
id: `imported-image-${Date.now()}-${i}`,
type: 'image',
src: imgInside.getAttribute("src") || "",
url: simpleLink.getAttribute("href") || "",
alt: imgInside.getAttribute("alt") || "",
bgColor,
padding,
align,
width: imgInside.getAttribute("width") || "100%"
});
continue;
} else {
blocksList.push({
id: `imported-link-${Date.now()}-${i}`,
type: 'link',
content: simpleLink.textContent?.trim() || "",
url: simpleLink.getAttribute("href") || "",
textColor: simpleLink.style.color || "#f37037",
bgColor,
padding,
align,
fontSize: simpleLink.style.fontSize?.replace("px", "") || "14",
fontFamily: simpleLink.style.fontFamily || 'Helvetica, Arial, sans-serif'
});
continue;
}
}
// D. Image sans lien
const imgTag = td.querySelector('img');
if (imgTag && !td.textContent?.includes("Pour adhérer") && !tr.innerHTML.includes("slogan")) {
blocksList.push({
id: `imported-image-${Date.now()}-${i}`,
type: 'image',
src: imgTag.getAttribute("src") || "",
url: "",
alt: imgTag.getAttribute("alt") || "",
bgColor,
padding,
align,
width: imgTag.getAttribute("width") || "100%"
});
continue;
}
// E. Espace (Spacer)
if (td.style.height && td.style.height !== "1px" && !td.textContent?.trim()) {
blocksList.push({
id: `imported-spacer-${Date.now()}-${i}`,
type: 'spacer',
height: td.style.height.replace("px", ""),
bgColor
});
continue;
}
// F. En-tête / Header (HTML non converti en image)
if (h1Tag) {
const subtitleDiv = td.querySelector('div');
const bgImgAttr = td.style.backgroundImage || "";
let bgImgUrl = "";
const bgMatch = bgImgAttr.match(/url\(['"]?(.*?)['"]?\)/);
if (bgMatch) bgImgUrl = bgMatch[1];
blocksList.push({
id: `imported-header-${Date.now()}-${i}`,
type: 'header',
content: h1Tag.innerHTML.replace(/<br>/g, "\n"),
subtitle: subtitleDiv ? subtitleDiv.textContent || "" : "",
bgColor,
backgroundImage: bgImgUrl,
url: simpleLink ? simpleLink.getAttribute("href") || "" : "",
exportAsImage: false,
textColor: h1Tag.style.color || "#800080",
subtitleColor: subtitleDiv ? subtitleDiv.style.color || "#f37037" : "#f37037",
padding,
align,
fontSize: h1Tag.style.fontSize?.replace("px", "") || "42",
subtitleFontSize: subtitleDiv ? subtitleDiv.style.fontSize?.replace("px", "") || "24" : "24",
fontFamily: h1Tag.style.fontFamily || 'Helvetica, Arial, sans-serif'
});
continue;
}
// G. Titre
if (h2Tag) {
blocksList.push({
id: `imported-title-${Date.now()}-${i}`,
type: 'title',
content: h2Tag.innerHTML.replace(/<br>/g, "\n"),
bgColor,
textColor: h2Tag.style.color || "#800080",
padding,
align,
fontSize: h2Tag.style.fontSize?.replace("px", "") || "20",
fontFamily: h2Tag.style.fontFamily || 'Helvetica, Arial, sans-serif'
});
continue;
}
// H. Pied de page / Footer (Identifié sur la première ligne)
if (td.textContent?.includes("Pour adhérer") || tr.innerHTML.includes("slogan")) {
let titleTop = td.textContent?.trim() || "Pour adhérer";
let btn1Text = "CONTACTER TA SECTION";
let btn1Url = "";
let btn1Color = "#800040";
let btn2Text = "COTISER EN LIGNE";
let btn2Url = "";
let btn2Color = "#22c55e";
let logoSrc = "";
let logoWidth = "80";
let slogan = "";
let address = "";
let legalText = "";
// Chercher les détails dans les lignes suivantes (le footer en génère plusieurs)
for (let j = i + 1; j < Math.min(i + 8, rows.length); j++) {
const nextTd = rows[j].querySelector('td');
if (!nextTd) continue;
const links = nextTd.querySelectorAll('a');
if (links.length >= 2) {
btn1Text = links[0].textContent?.trim() || btn1Text;
btn1Url = links[0].getAttribute("href") || btn1Url;
const path1 = links[0].closest('td');
btn1Color = path1?.getAttribute("bgcolor") || btn1Color;
btn2Text = links[1].textContent?.trim() || btn2Text;
btn2Url = links[1].getAttribute("href") || btn2Url;
const path2 = links[1].closest('td');
btn2Color = path2?.getAttribute("bgcolor") || btn2Color;
}
const footerImg = nextTd.querySelector('img');
if (footerImg) {
logoSrc = footerImg.getAttribute("src") || logoSrc;
logoWidth = footerImg.getAttribute("width") || logoWidth;
const sloganDiv = nextTd.querySelector('div');
if (sloganDiv) {
slogan = sloganDiv.textContent?.trim() || slogan;
}
const addrDiv = nextTd.querySelector('div[style*="color: #333"]');
if (addrDiv) {
address = addrDiv.innerHTML.replace(/<br>/g, "\n");
}
}
const bgCheck = nextTd.getAttribute("bgcolor") || nextTd.style.backgroundColor;
if (bgCheck && (bgCheck.includes("f9f9f9") || bgCheck.includes("249"))) {
legalText = nextTd.textContent?.trim() || legalText;
}
}
blocksList.push({
id: `imported-footer-${Date.now()}-${i}`,
type: 'footer',
titleTop,
btn1Text,
btn1Url,
btn1Color,
btn2Text,
btn2Url,
btn2Color,
logoSrc,
logoWidth,
slogan,
address,
legalText,
bgColor: "#ffffff"
});
// Sauter les lignes associées au footer déjà traitées
i += 5;
continue;
}
// I. Bloc Texte (Paragraphes HTML standard ou WYSIWYG)
if (td.style.color || td.className.includes("wysiwyg") || td.innerHTML.includes("<p>")) {
blocksList.push({
id: `imported-text-${Date.now()}-${i}`,
type: 'text',
content: td.innerHTML.trim(),
bgColor,
textColor: td.style.color || '#4a4a4a',
padding,
align,
fontSize: td.style.fontSize?.replace("px", "") || "16",
lineHeight: td.style.lineHeight || "1.5",
fontFamily: td.style.fontFamily || 'Helvetica, Arial, sans-serif'
});
continue;
}
}
if (blocksList.length > 0) {
parsedData = {
blocks: blocksList,
globalBg: bodyBg
};
}
}
} catch (err) {
console.error("Échec parsing DOM fallback", err);
}
}
if (parsedData && parsedData.blocks && Array.isArray(parsedData.blocks)) {
setBlocks(parsedData.blocks);
if (parsedData.globalBg) {
setGlobalBg(parsedData.globalBg);
}
setSelectedBlockId(null);
alert("Projet importé avec succès ! Les structures et contenus HTML ont été restaurés.");
} else {
alert("Impossible de lire la structure de ce fichier HTML comme un projet compatible.");
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<div className="flex h-screen w-full bg-gray-100 text-sm text-slate-800 font-sans overflow-hidden">
<style>{`@import url('https://fonts.googleapis.com/css2?family=Luckiest+Guy&family=Open+Sans:wght@400;700&family=Roboto:wght@400;700&display=swap');`}</style>
@@ -666,6 +1041,12 @@ p { margin-top: 0; margin-bottom: 10px; }
<Save size={14} /> Sauvegarder Projet
</button>
</Tooltip>
<Tooltip text="Importer un fichier HTML précédemment exporté pour reprendre l'édition.">
<label className="w-full flex items-center justify-center gap-1 bg-white border border-orange-200 text-orange-700 hover:bg-orange-100 py-2 rounded text-xs font-medium cursor-pointer">
<Upload size={14} /> Importer HTML
<input type="file" accept=".html" onChange={importHTML} className="hidden" />
</label>
</Tooltip>
<div className="flex gap-2">
<Tooltip text="Remet à zéro le contenu (titre, texte, liens) mais garde votre en-tête et pied de page personnalisés." className="flex-1">
<button onClick={resetBody} className="w-full flex items-center justify-center gap-1 bg-white border border-yellow-500 text-yellow-700 hover:bg-yellow-50 py-2 rounded text-xs font-medium">
@@ -738,7 +1119,21 @@ p { margin-top: 0; margin-bottom: 10px; }
)}
</div>
<div className="p-4 border-t border-gray-200 bg-slate-50">
<div className="p-4 border-t border-gray-200 bg-slate-50 space-y-3">
<label className="flex gap-2 cursor-pointer text-xs font-medium text-slate-700 items-start">
<input
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
className="cursor-pointer mt-0.5"
/>
<div>
<span className="block mb-0.5 font-bold">Fichier -importable</span>
<span className="text-[10px] text-slate-500 font-normal leading-tight block">
Ajoute des données invisibles (en commentaire HTML) pour pouvoir -ouvrir et modifier cette newsletter plus tard dans le builder.
</span>
</div>
</label>
<Tooltip text="Générer et télécharger le fichier HTML final prêt à l'envoi.">
<button onClick={downloadHTML} disabled={isExporting} className={`w-full flex items-center justify-center gap-2 py-3 px-4 rounded-md font-medium transition-colors shadow-sm ${isExporting ? 'bg-indigo-400 cursor-wait' : 'bg-indigo-600 hover:bg-indigo-700'} text-white`}>
{isExporting ? <><Loader2 className="animate-spin" size={18} /> Traitement...</> : <><Download size={18} /> Exporter HTML</>}
@@ -1107,7 +1502,19 @@ function PreviewBlock({ block }: { block: any }) {
</div>
);
case 'text':
return <div style={containerStyle} dangerouslySetInnerHTML={{ __html: block.content }} />;
return (
<div
className="wysiwyg"
style={{
...containerStyle,
color: block.textColor,
fontSize: `${block.fontSize}px`,
lineHeight: block.lineHeight || '1.5',
fontFamily: fontFamily
}}
dangerouslySetInnerHTML={{ __html: block.content }}
/>
);
case 'link':
return (
<div style={containerStyle}>

View File

@@ -1 +1,24 @@
@import "tailwindcss";
.wysiwyg ul {
list-style-type: disc !important;
padding-left: 1.5rem !important;
margin-top: 0 !important;
margin-bottom: 0.5rem !important;
}
.wysiwyg ol {
list-style-type: decimal !important;
padding-left: 1.5rem !important;
margin-top: 0 !important;
margin-bottom: 0.5rem !important;
}
.wysiwyg li {
margin-bottom: 0.25rem !important;
}
.wysiwyg p {
margin-top: 0 !important;
margin-bottom: 0.5rem !important;
}