From ec75ee32da56a37036baea7bebd80baaa9956e2b Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 14 Jul 2026 14:53:22 +0200 Subject: [PATCH] Fix Outlook email copy formatting --- web/src/components/admin/AdminEmail.jsx | 34 +------------- web/src/components/admin/AdminTabs.jsx | 41 +---------------- web/src/lib/emailClipboard.js | 61 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 71 deletions(-) create mode 100644 web/src/lib/emailClipboard.js diff --git a/web/src/components/admin/AdminEmail.jsx b/web/src/components/admin/AdminEmail.jsx index 0766e3f..5d7cd6d 100644 --- a/web/src/components/admin/AdminEmail.jsx +++ b/web/src/components/admin/AdminEmail.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { api } from '../../lib/api.js'; +import { copyRichEmail } from '../../lib/emailClipboard.js'; const INITIAL = { subject: 'Actualités du Business Club SLUC Nancy', @@ -11,27 +12,6 @@ const INITIAL = { signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy", }; -function bodyFragment(html) { - return new DOMParser().parseFromString(html, 'text/html').body.innerHTML.trim(); -} - -function legacyCopy(html) { - const holder = document.createElement('div'); - holder.contentEditable = 'true'; - holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:700px;opacity:0;pointer-events:none;'; - holder.innerHTML = html; - document.body.appendChild(holder); - const range = document.createRange(); - range.selectNodeContents(holder); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - const copied = document.execCommand('copy'); - selection.removeAllRanges(); - holder.remove(); - if (!copied) throw new Error('Rich copy failed'); -} - export function EmailCreatorTab() { const [form, setForm] = useState(INITIAL); const [data, setData] = useState(null); @@ -69,17 +49,7 @@ export function EmailCreatorTab() { const copyEmail = async () => { try { - const fragment = bodyFragment(data.html); - if (navigator.clipboard?.write && window.ClipboardItem) { - await navigator.clipboard.write([ - new window.ClipboardItem({ - 'text/html': new Blob([fragment], { type: 'text/html' }), - 'text/plain': new Blob([`${form.subject}\n\n${form.body}`], { type: 'text/plain' }), - }), - ]); - } else { - legacyCopy(fragment); - } + await copyRichEmail(data.html, `${form.subject}\n\n${form.body}`); flash('email'); } catch { setError('La copie enrichie a échoué dans ce navigateur. Utilisez le téléchargement HTML.'); diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index 8a1758d..4cecb7b 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js'; +import { copyRichEmail } from '../../lib/emailClipboard.js'; import { useAuth } from '../../lib/AuthContext.jsx'; import Modal from '../Modal.jsx'; import ImageSlot from '../ImageSlot.jsx'; @@ -319,34 +320,6 @@ function escHtml(v) { return String(v ?? '').replace(/&/g, '&').replace(//g, '>'); } -// Clipboard HTML must be a fragment, not a second complete document. Outlook -// otherwise rebuilds the pasted and drops part of the outer layout. -function emailBodyFragment(html) { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.innerHTML.trim(); -} - -// Older browsers do not expose ClipboardItem. A real rich-text selection is -// still preferable to copying the HTML source as plain text. -function legacyCopyRichHtml(html) { - const holder = document.createElement('div'); - holder.setAttribute('contenteditable', 'true'); - holder.setAttribute('aria-hidden', 'true'); - holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:700px;opacity:0;pointer-events:none;'; - holder.innerHTML = html; - document.body.appendChild(holder); - - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(holder); - selection.removeAllRanges(); - selection.addRange(range); - const copied = document.execCommand('copy'); - selection.removeAllRanges(); - holder.remove(); - if (!copied) throw new Error('Rich HTML copy failed'); -} - // Composer + preview of the ready-to-send HTML invitation email generated // server-side for one rencontre. Every text zone (salutation, intro, // conclusion, signature) is editable — the preview regenerates as the @@ -402,17 +375,7 @@ function EmailModal({ renc, onClose }) { const copyEmail = async () => { setError(''); try { - const fragment = emailBodyFragment(data.html); - if (navigator.clipboard?.write && window.ClipboardItem) { - await navigator.clipboard.write([ - new window.ClipboardItem({ - 'text/html': new Blob([fragment], { type: 'text/html' }), - 'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }), - }), - ]); - } else { - legacyCopyRichHtml(fragment); - } + await copyRichEmail(data.html, `${data.subject}\n${data.inscriptionUrl}`); flash('email'); } catch { setError('Copie impossible dans ce navigateur — utilisez le téléchargement.'); diff --git a/web/src/lib/emailClipboard.js b/web/src/lib/emailClipboard.js new file mode 100644 index 0000000..ccc52b1 --- /dev/null +++ b/web/src/lib/emailClipboard.js @@ -0,0 +1,61 @@ +function emailBodyFragment(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + return doc.body.innerHTML.trim(); +} + +function copyRenderedSelection(html) { + const holder = document.createElement('div'); + holder.setAttribute('contenteditable', 'true'); + holder.setAttribute('aria-hidden', 'true'); + holder.style.cssText = [ + 'position:fixed', + 'left:-10000px', + 'top:0', + 'width:700px', + 'background:#F2EEE8', + 'pointer-events:none', + 'z-index:-1', + ].join(';'); + holder.innerHTML = html; + document.body.appendChild(holder); + + const selection = window.getSelection(); + const range = document.createRange(); + + try { + holder.focus(); + range.selectNodeContents(holder); + selection.removeAllRanges(); + selection.addRange(range); + + if (!document.execCommand('copy')) { + throw new Error("La copie de l'e-mail a échoué."); + } + } finally { + selection.removeAllRanges(); + holder.remove(); + } +} + +export async function copyRichEmail(html, plainText = '') { + const fragment = emailBodyFragment(html); + + try { + // Outlook conserve beaucoup mieux la mise en forme lorsque le presse-papiers + // provient d'une sélection réellement rendue par le navigateur. + copyRenderedSelection(fragment); + return; + } catch (selectionError) { + if (!navigator.clipboard?.write || !window.ClipboardItem) { + throw selectionError; + } + } + + // Repli pour les navigateurs qui refusent execCommand('copy'). + await navigator.clipboard.write([ + new window.ClipboardItem({ + 'text/html': new Blob([fragment], { type: 'text/html' }), + 'text/plain': new Blob([plainText], { type: 'text/plain' }), + }), + ]); +}