/* ========================================================================== Panneau CV — Eliora Quand l'utilisateur choisit son CV dans un champ de la page (ici #ecv-fichier, le champ de eliora_cv_widget.php), le fichier s'ouvre dans un panneau à droite de la conversation. Formats affichés : PDF, Word (.docx), texte (.txt), PNG et JPG. L'envoi du CV vers eliora_cv.php n'est pas modifié : ce script se contente d'afficher le fichier, il n'envoie rien. Appel direct possible depuis un autre script : ElioraCV.ouvrir(fichier); // fichier = objet File ElioraCV.fermer(); Styles : css/cv_panneau.css ========================================================================== */ (function () { 'use strict'; var CONFIG = { // Lecteurs chargés seulement au premier CV ouvert. Pour ne pas dépendre // des CDN, copier ces fichiers dans js/ et remplacer les adresses. pdfJs: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', pdfWorker: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js', mammoth: 'https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js', // lecteur Word tailleMax: 3 * 1024 * 1024, // même limite que le widget (3 Mo) : au-delà, le panneau ne s'ouvre pas pagesMax: 20, // nombre maximal de pages PDF affichées largeurMin: 360, // largeur minimale du panneau (px) conversationMin: 480, // largeur minimale laissée à la conversation (px) seuilPleinEcran: 900 // sous cette largeur de fenêtre, le panneau prend tout l'écran }; var ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2]; var CLE_LARGEUR = 'eliora-cv-largeur'; var panneau, scene, pages, note, nomEl, metaEl, zoomEl, btnMoins, btnPlus, btnFermer, poignee, onglet; var courant = null; // document affiché var indexZoom = 2; var largeurVoulue = null; var scriptsCharges = {}; /* ---------- Construction du panneau ---------- */ function construire() { panneau = document.createElement('div'); panneau.id = 'cv-panneau'; panneau.setAttribute('role', 'complementary'); panneau.setAttribute('aria-label', 'Aperçu du CV'); panneau.innerHTML = '' + '
' + '
' + '
' + '' + '100 %' + '' + '' + '
' + '
' + '

'; document.body.appendChild(panneau); onglet = document.createElement('button'); onglet.type = 'button'; onglet.id = 'cv-onglet'; onglet.innerHTML = '' + 'Voir mon CV'; document.body.appendChild(onglet); scene = document.getElementById('cv-scene'); pages = document.getElementById('cv-pages'); note = document.getElementById('cv-note'); nomEl = document.getElementById('cv-nom'); metaEl = document.getElementById('cv-meta'); zoomEl = document.getElementById('cv-zoom'); btnMoins = document.getElementById('cv-moins'); btnPlus = document.getElementById('cv-plus'); btnFermer = document.getElementById('cv-fermer'); poignee = document.getElementById('cv-poignee'); btnFermer.addEventListener('click', fermer); onglet.addEventListener('click', function () { montrer(); btnFermer.focus(); }); btnMoins.addEventListener('click', function () { if (indexZoom > 0) { indexZoom--; appliquerZoom(); } }); btnPlus.addEventListener('click', function () { if (indexZoom < ZOOMS.length - 1) { indexZoom++; appliquerZoom(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && document.body.classList.contains('cv-ouvert')) { fermer(); } }); brancherPoignee(); window.addEventListener('resize', ajusterLargeur); try { var memo = parseInt(localStorage.getItem(CLE_LARGEUR), 10); if (memo > 0) { largeurVoulue = memo; } } catch (e) {} appliquerZoom(); ajusterLargeur(); } /* ---------- Ouverture / fermeture ---------- */ function montrer() { document.body.classList.add('cv-ouvert'); ajusterLargeur(); } function fermer() { if (!document.body.classList.contains('cv-ouvert')) { return; } document.body.classList.remove('cv-ouvert'); if (courant) { onglet.focus(); } } function ecrireNote(texte) { note.textContent = texte || ''; note.style.display = texte ? 'block' : 'none'; } function appliquerZoom() { var z = ZOOMS[indexZoom]; pages.style.setProperty('--cv-zoom', z); zoomEl.textContent = Math.round(z * 100) + ' %'; btnMoins.disabled = indexZoom === 0; btnPlus.disabled = indexZoom === ZOOMS.length - 1; } /* ---------- Largeur du panneau ---------- */ function ajusterLargeur() { var fenetre = window.innerWidth; var plein = fenetre < CONFIG.seuilPleinEcran; document.body.classList.toggle('cv-plein', plein); if (plein) { return; } var voulue = largeurVoulue || Math.round(fenetre * 0.46); var max = Math.max(CONFIG.largeurMin, fenetre - CONFIG.conversationMin); var w = Math.max(CONFIG.largeurMin, Math.min(voulue, max)); document.documentElement.style.setProperty('--cv-w', w + 'px'); poignee.setAttribute('aria-valuenow', Math.round(w / fenetre * 100)); } function memoriserLargeur() { try { localStorage.setItem(CLE_LARGEUR, String(largeurVoulue)); } catch (e) {} } function brancherPoignee() { var actif = false; poignee.addEventListener('pointerdown', function (e) { actif = true; poignee.setPointerCapture(e.pointerId); document.body.classList.add('cv-redim'); e.preventDefault(); }); poignee.addEventListener('pointermove', function (e) { if (!actif) { return; } largeurVoulue = Math.round(window.innerWidth - e.clientX); ajusterLargeur(); }); function fin() { if (!actif) { return; } actif = false; document.body.classList.remove('cv-redim'); memoriserLargeur(); } poignee.addEventListener('pointerup', fin); poignee.addEventListener('pointercancel', fin); poignee.addEventListener('keydown', function (e) { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { return; } e.preventDefault(); largeurVoulue = Math.round(panneau.getBoundingClientRect().width + (e.key === 'ArrowLeft' ? 32 : -32)); ajusterLargeur(); memoriserLargeur(); }); } /* ---------- Lecture du fichier ---------- */ function typeDe(fichier) { var n = (fichier.name || '').toLowerCase(); if (fichier.type === 'application/pdf' || /\.pdf$/.test(n)) { return 'pdf'; } if (/\.docx$/.test(n)) { return 'docx'; } if (/\.txt$/.test(n)) { return 'texte'; } if (fichier.type === 'image/png' || fichier.type === 'image/jpeg' || /\.(png|jpe?g)$/.test(n)) { return 'image'; } return null; } function taille(octets) { if (octets < 1024 * 1024) { return Math.max(1, Math.round(octets / 1024)) + ' ko'; } return (octets / (1024 * 1024)).toFixed(1).replace('.', ',') + ' Mo'; } // Charge un script externe une seule fois et renvoie l'objet global qu'il définit. function chargerScript(adresse, nomGlobal) { if (window[nomGlobal]) { return Promise.resolve(window[nomGlobal]); } if (!scriptsCharges[adresse]) { scriptsCharges[adresse] = new Promise(function (ok, echec) { var s = document.createElement('script'); s.src = adresse; s.onload = function () { if (window[nomGlobal]) { ok(window[nomGlobal]); } else { delete scriptsCharges[adresse]; echec(new Error('LECTEUR')); } }; s.onerror = function () { delete scriptsCharges[adresse]; echec(new Error('LECTEUR')); }; document.head.appendChild(s); }); } return scriptsCharges[adresse]; } function chargerPdfJs() { return chargerScript(CONFIG.pdfJs, 'pdfjsLib').then(function (pdfjs) { pdfjs.GlobalWorkerOptions.workerSrc = pdfjs.GlobalWorkerOptions.workerSrc || CONFIG.pdfWorker; return pdfjs; }); } function ajouterPage(doc, noeud) { if (courant !== doc) { return; } // un autre CV a été choisi entre-temps pages.appendChild(noeud); } function afficherPdf(fichier, doc) { return chargerPdfJs().then(function (pdfjs) { return fichier.arrayBuffer().then(function (tampon) { return pdfjs.getDocument({ data: new Uint8Array(tampon), isEvalSupported: false }).promise; }); }).then(function (pdf) { var total = pdf.numPages, affichees = Math.min(total, CONFIG.pagesMax); var dpr = Math.min(window.devicePixelRatio || 1, 2); var cible = Math.min(1800, Math.max(1000, (scene.clientWidth || 700) * dpr * 1.25)); doc.pages = total; doc.tronque = total > affichees; var suite = Promise.resolve(); for (var i = 1; i <= affichees; i++) { (function (numero) { suite = suite.then(function () { if (courant !== doc) { return null; } return pdf.getPage(numero).then(function (page) { var base = page.getViewport({ scale: 1 }); var vue = page.getViewport({ scale: cible / base.width }); var c = document.createElement('canvas'); c.className = 'cv-page'; c.width = Math.round(vue.width); c.height = Math.round(vue.height); c.setAttribute('role', 'img'); c.setAttribute('aria-label', 'Page ' + numero + ' sur ' + total); return page.render({ canvasContext: c.getContext('2d'), viewport: vue }).promise.then(function () { ajouterPage(doc, c); }); }); }); })(i); } return suite; }); } function afficherImage(fichier, doc) { return new Promise(function (ok, echec) { var img = new Image(); img.className = 'cv-page'; img.alt = 'CV : ' + fichier.name; img.onload = function () { doc.pages = 1; ajouterPage(doc, img); ok(); }; img.onerror = function () { echec(new Error('IMAGE')); }; img.src = URL.createObjectURL(fichier); }); } function feuille() { var f = document.createElement('div'); f.className = 'cv-page cv-feuille'; return f; } function afficherTexte(fichier, doc) { return fichier.text().then(function (texte) { var f = feuille(); f.classList.add('cv-texte'); f.textContent = texte; ajouterPage(doc, f); }); } // Le HTML produit à partir du fichier Word n'est pas inséré tel quel : on ne garde // que des balises de mise en forme, sans attribut, sans lien et sans script. var BALISES_OK = { P: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, UL: 1, OL: 1, LI: 1, STRONG: 1, EM: 1, B: 1, I: 1, U: 1, S: 1, SUP: 1, SUB: 1, BR: 1, TABLE: 1, THEAD: 1, TBODY: 1, TR: 1, TD: 1, TH: 1, IMG: 1, SPAN: 1, DIV: 1 }; function nettoyer(source, cible) { for (var i = 0; i < source.childNodes.length; i++) { var n = source.childNodes[i]; if (n.nodeType === 3) { cible.appendChild(document.createTextNode(n.nodeValue)); continue; } if (n.nodeType !== 1) { continue; } var nom = n.tagName.toUpperCase(); if (nom === 'SCRIPT' || nom === 'STYLE' || nom === 'IFRAME' || nom === 'OBJECT' || nom === 'EMBED') { continue; } if (!BALISES_OK[nom]) { nettoyer(n, cible); continue; } // balise inconnue (lien, etc.) : on garde seulement son texte var copie = document.createElement(nom); if (nom === 'IMG') { var src = n.getAttribute('src') || ''; if (!/^data:image\/(png|jpe?g|gif);base64,/i.test(src)) { continue; } copie.setAttribute('src', src); copie.setAttribute('alt', ''); } if (nom === 'TD' || nom === 'TH') { var c = parseInt(n.getAttribute('colspan'), 10), r = parseInt(n.getAttribute('rowspan'), 10); if (c > 1) { copie.setAttribute('colspan', c); } if (r > 1) { copie.setAttribute('rowspan', r); } } nettoyer(n, copie); cible.appendChild(copie); } } function afficherDocx(fichier, doc) { return chargerScript(CONFIG.mammoth, 'mammoth').then(function (mammoth) { return fichier.arrayBuffer().then(function (tampon) { return mammoth.convertToHtml({ arrayBuffer: tampon }); }); }).then(function (resultat) { var brut = new DOMParser().parseFromString('
' + (resultat.value || '') + '
', 'text/html'); var f = feuille(); nettoyer(brut.body, f); ajouterPage(doc, f); }); } function messageErreur(err) { if (err && err.message === 'LECTEUR') { return 'Le lecteur de documents ne s\'est pas chargé. Vérifiez votre connexion puis choisissez à nouveau votre CV.'; } if (err && err.name === 'PasswordException') { return 'Ce PDF est protégé par un mot de passe : l\'aperçu n\'est pas possible.'; } return 'L\'aperçu de ce fichier n\'est pas possible : il est illisible ou endommagé.'; } /* ---------- Point d'entrée ---------- */ function ouvrir(fichier) { if (!fichier) { return false; } var type = typeDe(fichier); if (!type) { return false; } // format sans aperçu : on ne touche à rien if (fichier.size > CONFIG.tailleMax) { return false; } // refusé par le widget : pas de panneau var doc = { nom: fichier.name, pages: 0, tronque: false }; courant = doc; document.body.classList.add('cv-charge'); pages.replaceChildren(); nomEl.textContent = fichier.name; metaEl.textContent = taille(fichier.size); scene.scrollTop = 0; scene.scrollLeft = 0; montrer(); ecrireNote('Ouverture du CV…'); var rendu = type === 'pdf' ? afficherPdf(fichier, doc) : type === 'docx' ? afficherDocx(fichier, doc) : type === 'texte' ? afficherTexte(fichier, doc) : afficherImage(fichier, doc); rendu.then(function () { if (courant !== doc) { return; } ecrireNote(''); var detail = type === 'pdf' ? doc.pages + (doc.pages > 1 ? ' pages' : ' page') : type === 'docx' ? 'Word' : type === 'texte' ? 'Texte' : 'Image'; metaEl.textContent = detail + ' · ' + taille(fichier.size) + (doc.tronque ? ' · ' + CONFIG.pagesMax + ' premières pages affichées' : '') + (type === 'docx' ? ' · aperçu simplifié, la mise en page peut différer' : ''); }).catch(function (err) { if (courant !== doc) { return; } pages.replaceChildren(); ecrireNote(messageErreur(err)); }); return true; } function demarrer() { construire(); // Écoute tous les champs « fichier » de la page, y compris ceux ajoutés plus tard. document.addEventListener('change', function (e) { var champ = e.target; if (champ && champ.tagName === 'INPUT' && champ.type === 'file' && champ.files && champ.files[0]) { ouvrir(champ.files[0]); } }, true); } window.ElioraCV = { ouvrir: ouvrir, fermer: fermer }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', demarrer); } else { demarrer(); } })();