Fix Outlook email copy formatting

This commit is contained in:
Michael committed 2026-07-14 14:53:22 +02:00
1 parent f00fdffac6
commit ec75ee32da
3 files changed
+65 -71

No files matched your search

+2 -32
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { api } from '../../lib/api.js'; import { api } from '../../lib/api.js';
import { copyRichEmail } from '../../lib/emailClipboard.js';
const INITIAL = { const INITIAL = {
subject: 'Actualités du Business Club SLUC Nancy', 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", 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() { export function EmailCreatorTab() {
const [form, setForm] = useState(INITIAL); const [form, setForm] = useState(INITIAL);
const [data, setData] = useState(null); const [data, setData] = useState(null);
@@ -69,17 +49,7 @@ export function EmailCreatorTab() {
const copyEmail = async () => { const copyEmail = async () => {
try { try {
const fragment = bodyFragment(data.html); await copyRichEmail(data.html, `${form.subject}\n\n${form.body}`);
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);
}
flash('email'); flash('email');
} catch { } catch {
setError('La copie enrichie a échoué dans ce navigateur. Utilisez le téléchargement HTML.'); setError('La copie enrichie a échoué dans ce navigateur. Utilisez le téléchargement HTML.');
+2 -39
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js'; import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { copyRichEmail } from '../../lib/emailClipboard.js';
import { useAuth } from '../../lib/AuthContext.jsx'; import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx'; import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx'; import ImageSlot from '../ImageSlot.jsx';
@@ -319,34 +320,6 @@ function escHtml(v) {
return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
// Clipboard HTML must be a fragment, not a second complete document. Outlook
// otherwise rebuilds the pasted <body> 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 // Composer + preview of the ready-to-send HTML invitation email generated
// server-side for one rencontre. Every text zone (salutation, intro, // server-side for one rencontre. Every text zone (salutation, intro,
// conclusion, signature) is editable — the preview regenerates as the // conclusion, signature) is editable — the preview regenerates as the
@@ -402,17 +375,7 @@ function EmailModal({ renc, onClose }) {
const copyEmail = async () => { const copyEmail = async () => {
setError(''); setError('');
try { try {
const fragment = emailBodyFragment(data.html); await copyRichEmail(data.html, `${data.subject}\n${data.inscriptionUrl}`);
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);
}
flash('email'); flash('email');
} catch { } catch {
setError('Copie impossible dans ce navigateur — utilisez le téléchargement.'); setError('Copie impossible dans ce navigateur — utilisez le téléchargement.');
+61
View File
@@ -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' }),
}),
]);
}