From 02524e78b27382af7d7d47960b19b2f6ed60e758 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 24 Apr 2026 12:56:34 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.34=20=E2=80=94=20Bouton=20=F0=9F=93=8C?= =?UTF-8?q?=20restaur=C3=A9=20+=20badge=20user=20cliquable=20+=20positionT?= =?UTF-8?q?ooltipAnchored=20unifi=C3=A9e=20[code=20interpol=C3=A9]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- viewer.html | 11 +- viewer.js | 581 ++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 427 insertions(+), 167 deletions(-) diff --git a/manifest.json b/manifest.json index 2ecf0a4..816940d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.33", + "version": "2026.5.34", "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "host_permissions": [ diff --git a/viewer.html b/viewer.html index 5164111..cf61b84 100644 --- a/viewer.html +++ b/viewer.html @@ -9,10 +9,15 @@
- + title="Utilisateur — cliquer pour accéder aux paramètres">?

Planification

diff --git a/viewer.js b/viewer.js index 6c9b62b..45f39ff 100644 --- a/viewer.js +++ b/viewer.js @@ -326,64 +326,122 @@ function markSessionActivity() { } // v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et -// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé, -// le badge reste caché. -// v2026.5.26 : en cas d'échec, afficher quand même un rond vide avec "?" -// pour que l'user puisse ouvrir le popup (bouton Paramètres). Et retry -// automatique toutes les 60s (max 10 essais = 10 min) pour récupérer le user -// dès qu'il devient disponible (ex: après reconnexion SSO). +// l'affiche dans la topbar. +// v2026.5.26 : en cas d'échec, affiche un rond gris "?" + retry 60s (max 10 essais). +// v2026.5.34 : le badge est maintenant TOUJOURS visible (état "?" par défaut +// dans le HTML). Cette fonction met à jour le contenu (initiales +// quand succès, "?" quand échec). Logs abondants pour debug. +// +// État initial (HTML) : +// État succès : initiales calculées + couleur dérivée du nom +// État échec : "?" + couleur grise (classe user-badge-unknown) +// +// Retry : 10 tentatives espacées de 60s (10 min max), arrêt au 1er succès. let _currentUserRetryCount = 0; const _CURRENT_USER_MAX_RETRIES = 10; const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000; async function fetchAndShowCurrentUser() { + const attemptId = _currentUserRetryCount + 1; + console.log(`[currentUser] tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`); + + const badge = document.getElementById("user-badge"); + if (!badge) { + // Fallback défensif : pas de badge dans le DOM ? On log et on abandonne. + console.warn("[currentUser] badge DOM introuvable — abandon"); + return; + } + let success = false; + let errorReason = null; + try { const resp = await sendMessage({ type: "fetchCurrentUser" }); - if (resp && resp.ok && resp.user) { - const badge = document.getElementById("user-badge"); - if (badge) { - const fullName = resp.user.name || resp.user.login || null; - if (fullName) { - const initials = computeUserInitials(fullName); - badge.textContent = initials; - badge.title = fullName; - badge.style.setProperty("--user-badge-color", colorFromName(fullName)); - badge.classList.remove("hidden"); - badge.classList.remove("user-badge-unknown"); - state.currentUser = resp.user; - success = true; - _currentUserRetryCount = 0; // reset au succès - } + console.log("[currentUser] réponse reçue :", resp ? JSON.stringify(resp).substring(0, 200) : "(null)"); + + if (!resp) { + errorReason = "response_null"; + } else if (!resp.ok) { + errorReason = resp.error || "ok_false"; + } else if (!resp.user) { + errorReason = "user_null"; + } else { + const fullName = resp.user.name || resp.user.login || null; + if (!fullName) { + errorReason = "name_empty"; + } else { + // ✅ Succès : mise à jour du badge + const initials = computeUserInitials(fullName); + console.log(`[currentUser] SUCCÈS : "${fullName}" → initiales "${initials}"`); + badge.textContent = initials; + badge.title = fullName; + badge.style.setProperty("--user-badge-color", colorFromName(fullName)); + badge.classList.remove("user-badge-unknown"); + // On retire aussi "hidden" au cas où (compat ancienne version) + badge.classList.remove("hidden"); + state.currentUser = resp.user; + success = true; + _currentUserRetryCount = 0; // reset compteur au succès } } } catch (err) { - console.warn("[currentUser] fetch failed:", err); + errorReason = "exception: " + String(err); + console.warn("[currentUser] exception durant sendMessage :", err); } - // v2026.5.26 : échec → afficher rond vide avec "?" + scheduler retry - if (!success) { - const badge = document.getElementById("user-badge"); - if (badge) { - badge.textContent = "?"; - badge.title = "Utilisateur inconnu — cliquer pour accéder aux paramètres"; - badge.style.setProperty("--user-badge-color", "#6b7280"); - badge.classList.remove("hidden"); - badge.classList.add("user-badge-unknown"); - } - // Schedule retry si pas trop d'essais - if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) { - _currentUserRetryCount++; - console.log(`[currentUser] retry ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`); - setTimeout(() => { - fetchAndShowCurrentUser(); - }, _CURRENT_USER_RETRY_DELAY_MS); - } else { - console.warn("[currentUser] max retries atteint, arrêt"); - } + if (success) return; + + // ❌ Échec : on laisse le badge en état "inconnu" (déjà le cas par défaut) + // et on schedule un retry. + console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`); + + // Défense : s'assurer que le badge est bien en état inconnu (au cas où + // une mise à jour partielle a eu lieu puis échoué). + badge.textContent = "?"; + badge.title = "Utilisateur — cliquer pour accéder aux paramètres"; + badge.style.setProperty("--user-badge-color", "#6b7280"); + badge.classList.add("user-badge-unknown"); + badge.classList.remove("hidden"); + + // Schedule retry si pas trop d'essais + if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) { + _currentUserRetryCount++; + console.log(`[currentUser] retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`); + setTimeout(() => { + fetchAndShowCurrentUser(); + }, _CURRENT_USER_RETRY_DELAY_MS); + } else { + console.warn("[currentUser] max retries atteint, arrêt du retry automatique. Le badge reste cliquable (⚙ Paramètres accessible)."); } } +/** + * v2026.5.34 : déclenche un fetchAndShowCurrentUser() SI le user n'est pas + * encore connu (badge en état "?"). Appelée après chaque succès de planning + * pour profiter d'une session EV valide sans attendre le retry de 60s. + * + * Sans effet si : + * - state.currentUser est déjà renseigné (pas besoin de re-fetcher) + * - un retry est déjà en cours (évite les doublons) + * + * @param {string} reason - contexte pour les logs (ex: "after_load_success") + */ +function _maybeRetryFetchUser(reason) { + if (state.currentUser && state.currentUser.name) { + // User déjà connu, rien à faire + return; + } + const badge = document.getElementById("user-badge"); + if (badge && !badge.classList.contains("user-badge-unknown")) { + // Badge n'est pas en état inconnu → user probablement connu par un autre chemin + return; + } + console.log(`[currentUser] relance opportuniste (raison: ${reason}) — user encore inconnu`); + // Reset le compteur puisqu'on a un nouveau contexte (session fraîche) + _currentUserRetryCount = 0; + fetchAndShowCurrentUser(); +} + // v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou // "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque // mot/segment significatif, limité à 2 caractères. @@ -1154,6 +1212,10 @@ function initSessionTimer() { hideReconnectFailedBanner(); markSessionActivity(); showToast("Reconnecté", "Session EasyVista renouvelée"); + // v2026.5.34 : relancer fetchUser tout de suite (au lieu d'attendre + // le retry de 60s) — la session vient d'être renouvelée, c'est le + // meilleur moment pour récupérer le user. + _maybeRetryFetchUser("session_reconnected"); // Recharger le planning à la date courante sans perdre la position await loadForDate(state.currentDate); } @@ -2690,6 +2752,11 @@ async function loadForDate(isoDate, opts = {}) { console.log(`[load] merged : ${merged.techs.length} techs, ${totalIv} iv totales, ${totalInterIv} interventions réelles, ${notFetched} sans fiche`); console.log(`[load] needFetch = ${needFetch} | doStatusRefresh = ${!!opts.doStatusRefresh} | forceRefetch = ${!!opts.forceRefetch} | aborted = ${isRefreshAborted(myToken)}`); + // v2026.5.34 : si le user n'est pas encore connu (badge "?"), on tente + // un fetch immédiatement puisque le planning a réussi = session valide. + // Évite d'attendre le retry de 60s quand on vient juste de se reconnecter. + _maybeRetryFetchUser("after_load_success"); + // v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune), // on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer // les vraies infos contact/lieu de toutes les interventions en parallèle. @@ -5131,26 +5198,38 @@ function bindTimelinePopover(el) { }); } -// v4.2.3 : positionne la petite popup timeline à côté du curseur +/** + * v4.2.3 : positionne la petite popup timeline à côté du curseur. + * Utilisée UNIQUEMENT en vue classique pour la petite popup qui suit la souris + * quand on survole un segment timeline (informations courtes : durée, ref). + * + * v2026.5.34 : documentation + logs. Le clamp dans le viewport reste local + * (pas unifié avec positionTooltipAnchored car la logique "suit-souris" est + * fondamentalement différente d'un ancrage fixe à une source). + * + * @param {MouseEvent} e - événement souris pour position courante + */ function moveTimelineTooltip(e) { const tip = tooltipEl(); if (!tip || !tip.classList.contains("visible")) return; // La popup ancrée (grande bulle) ne doit pas être déplacée par la souris if (bulleState.pinned) return; - // Si la popup affiche une grande bulle d'intervention (classe pinned-like), + // Si la popup affiche une grande bulle d'intervention (mode anchored), // on ne la bouge pas non plus : on la laisse ancrée. if (tip.dataset.mode === "anchored") return; + const offsetX = 14, offsetY = 16; let x = e.clientX + offsetX; let y = e.clientY + offsetY; const rect = tip.getBoundingClientRect(); - // Ajuster si on sort de la fenêtre - if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offsetX; + + // Ajuster si on sort de la fenêtre (logique simple : flip autour du curseur) + if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offsetX; if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY; if (x < 4) x = 4; if (y < 4) y = 4; - // v4.2.4 : utiliser setTooltipViewportPosition pour bénéficier de la - // détection automatique fixed/abs (et donc de la stabilité au scroll). + + // setTooltipViewportPosition gère la détection auto fixed vs abs. setTooltipViewportPosition(x, y); } @@ -5184,19 +5263,46 @@ function findIvByActionId(actionId) { // v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline, ancrée // juste en dessous du segment. Pas épinglée : se ferme sur clic ailleurs, // Échap, OU quand la souris quitte la popup elle-même (mouseleave). +// v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline (vue +// classique) ou au hover (vue horizontale). Ancrée à côté du segment, pas +// sur. Se ferme au clic ailleurs. +// +// v2026.5.34 : utilise positionTooltipAnchored() unifié au lieu de recalculer +// sa propre position. Plus de code dupliqué. function openPersistentTimelinePopup(el) { + if (!el) { + console.warn("[persistentTimeline] segment el null — abandon"); + return; + } const ivIdx = el.dataset.ivIdx; - if (ivIdx === undefined) return; + if (ivIdx === undefined) { + console.log("[persistentTimeline] segment sans ivIdx (hole/absence vide) — abandon"); + return; + } const cardEl = el.closest(".card"); - if (!cardEl) return; + if (!cardEl) { + console.warn("[persistentTimeline] pas de .card parent trouvée"); + return; + } const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`); - if (!row) return; + if (!row) { + console.warn(`[persistentTimeline] row intervention-v2[data-iv-idx="${ivIdx}"] introuvable`); + return; + } const actionId = row.dataset.actionId; const iv = findIvByActionId(actionId); - if (!iv) return; + if (!iv) { + console.warn(`[persistentTimeline] iv pour actionId=${actionId} introuvable`); + return; + } const tip = tooltipEl(); - if (!tip) return; + if (!tip) { + console.warn("[persistentTimeline] tooltipEl() null"); + return; + } + + console.log(`[persistentTimeline] ouverture grande popup pour iv actionId=${actionId}`); // Nettoyer tout état précédent (ancrage, épinglage, timers) bulleState.pinned = false; @@ -5209,29 +5315,20 @@ function openPersistentTimelinePopup(el) { tip.innerHTML = buildTooltipHTML(iv); tip.classList.remove("hidden"); tip.classList.add("visible"); - // mode "anchored" : le hover ne doit pas la remplacer par une autre popup + // mode "anchored" : le hover timeline ne doit pas la remplacer par la petite popup tip.dataset.mode = "anchored"; state.currentTooltipIv = iv; - // Position : juste sous le segment timeline. D'abord on reset les coords - // pour que getBoundingClientRect() reflète la vraie taille du nouveau - // contenu. + // v2026.5.34 : utiliser positionTooltipAnchored() unifié, en préférant + // dessous (sous le segment timeline) via opts.anchorBelow = true. + // + // D'abord on reset les coords pour que le tipRect soit correctement mesuré + // avec le nouveau contenu. tip.style.left = "-9999px"; tip.style.top = "0px"; - // Forcer un reflow pour que tipRect soit à jour avec le nouveau contenu - const tipRect = tip.getBoundingClientRect(); - const r = el.getBoundingClientRect(); - let x = r.left; - let y = r.bottom + 8; - if (x + tipRect.width > window.innerWidth - 8) x = window.innerWidth - tipRect.width - 8; - if (x < 4) x = 4; - if (y + tipRect.height > window.innerHeight - 8) { - y = r.top - tipRect.height - 8; - } - if (y < 4) y = 4; - - // Positionner proprement (avec détection auto fixed vs abs) - setTooltipViewportPosition(x, y); + // Force reflow + void tip.offsetWidth; + positionTooltipAnchored(el, { anchorBelow: true }); } function showTimelinePopover(e, el) { @@ -6403,19 +6500,29 @@ function showTooltip(e, iv, rowEl) { // v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore // les mouseenter sur les cartes — sinon en survolant une carte on déclenche // l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger. - if (state._popupDragging) return; + if (state._popupDragging) { + console.log("[showTooltip] ignoré : popup drag en cours"); + return; + } // v2026.5.27 : fermer tout popup "soft-unpinned" encore visible (il traîne // parce que la souris était dessus). Dès qu'on survole autre chose, on veut // que seul le popup actuel ou les popups épinglés restent. - document.querySelectorAll(".soft-unpinned").forEach(el => { - try { el.remove(); } catch (err) {} - }); + // v2026.5.34 : le softUnpin ne supprime plus le popup au mouseleave, donc + // ici on les supprime explicitement quand un nouveau tooltip démarre. + const softUnpinned = document.querySelectorAll(".soft-unpinned"); + if (softUnpinned.length) { + console.log(`[showTooltip] suppression de ${softUnpinned.length} popup(s) soft-unpinned`); + softUnpinned.forEach(el => { + try { el.remove(); } catch (err) {} + }); + } // v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS // son contenu (l'user veut garder la fiche épinglée même en survolant // d'autres cartes). if (bulleState.pinned && state.currentTooltipIv && state.currentTooltipIv !== iv) { + console.log("[showTooltip] ignoré : tooltip épinglé sur une autre iv"); return; } @@ -6602,56 +6709,108 @@ function reapplyTooltipPosition() { el.style.top = ((el._absBasisTop || 0) + dy) + "px"; } -function positionTooltipAnchored(rowEl) { +/** + * v2026.5.34 : fonction UNIQUE et unifiée de positionnement ancré du tooltip. + * + * Utilisée par : + * - showTooltip() — hover d'une row intervention en vue classique + * - openPersistentTimelinePopup() — clic (classique) ou hover (horizontal) + * d'un segment timeline + * - showTooltip() pour les cartes/badges absence + * + * Algorithme : + * 1. Essaie 4 positions dans l'ordre : droite, gauche, dessous, dessus + * 2. Chaque position : padding de 8px min par rapport à la source + * 3. Chaque position : doit tenir dans la safe area (pas sous topbar/dock) + * 4. Chaque position : ne doit pas chevaucher les popups épinglés existants + * 5. Première position qui satisfait tout → on la prend + * 6. Fallback si aucune ne marche : droite clampée (la moins pire) + * + * La popup ne couvre JAMAIS la source (pad >= 8px en distance euclidienne). + * + * @param {HTMLElement} sourceEl - l'élément déclencheur (row, card, segment) + * @param {object} opts - options { anchorBelow: true pour préférer dessous } + */ +function positionTooltipAnchored(sourceEl, opts) { + opts = opts || {}; const el = tooltipEl(); - if (!rowEl || !el) return; - const pad = 14; - const rowRect = rowEl.getBoundingClientRect(); + if (!el) { + console.warn("[positionTooltip] tooltip DOM introuvable"); + return; + } + if (!sourceEl) { + console.warn("[positionTooltip] sourceEl null — pas de positionnement"); + return; + } + + const pad = 10; // padding entre source et popup + const viewportMargin = 8; // marge par rapport aux bords + const srcRect = sourceEl.getBoundingClientRect(); const tipRect = el.getBoundingClientRect(); - - // Position X : à droite de la ligne par défaut - let x = rowRect.right + pad; - if (x + tipRect.width > window.innerWidth - 8) { - x = rowRect.left - tipRect.width - pad; - } - if (x < 4) x = 4; - - // Position Y : aligné en haut de la ligne - let y = rowRect.top; - if (y + tipRect.height > window.innerHeight - 8) { - y = window.innerHeight - tipRect.height - 8; - } - if (y < 4) y = 4; - - // v2026.5.17 : éviter le chevauchement avec les popups épinglés existants. - // On teste la position candidate, et si elle chevauche un popup épinglé, - // on essaie d'autres candidats (gauche de la carte, au-dessous, au-dessus). - const tipW = tipRect.width || 320; + const tipW = tipRect.width || 320; // fallback taille si pas encore rendu const tipH = tipRect.height || 200; - const pinnedRects = _getPinnedPopupsViewportRects(); - if (pinnedRects.length) { - const candidates = [ - { x, y, label: "right" }, - { x: rowRect.left - tipW - pad, y: rowRect.top, label: "left" }, - { x: rowRect.left, y: rowRect.bottom + pad, label: "below" }, - { x: rowRect.left, y: rowRect.top - tipH - pad, label: "above" } - ]; - for (const c of candidates) { - // Borne dans le viewport - if (c.x < 4) c.x = 4; - if (c.x + tipW > window.innerWidth - 8) c.x = window.innerWidth - tipW - 8; - if (c.y < 4) c.y = 4; - if (c.y + tipH > window.innerHeight - 8) c.y = window.innerHeight - tipH - 8; - const testRect = { left: c.x, top: c.y, right: c.x + tipW, bottom: c.y + tipH }; - const overlaps = pinnedRects.some(pr => _rectsOverlap(testRect, pr)); - if (!overlaps) { - x = c.x; y = c.y; - break; - } - } + + // Safe area : respecter topbar en haut, dock en bas + const safe = (typeof _getPopupSafeArea === "function") + ? _getPopupSafeArea() + : { left: viewportMargin, top: viewportMargin, + right: window.innerWidth - viewportMargin, + bottom: window.innerHeight - viewportMargin }; + + // 4 candidats (ordre : droite → gauche → dessous → dessus) + // Préférence opts.anchorBelow = true : dessous en premier (ex: clic timeline) + const rightCandidate = { x: srcRect.right + pad, y: srcRect.top, label: "droite" }; + const leftCandidate = { x: srcRect.left - tipW - pad, y: srcRect.top, label: "gauche" }; + const belowCandidate = { x: srcRect.left, y: srcRect.bottom + pad, label: "dessous" }; + const aboveCandidate = { x: srcRect.left, y: srcRect.top - tipH - pad, label: "dessus" }; + + const candidates = opts.anchorBelow + ? [belowCandidate, aboveCandidate, rightCandidate, leftCandidate] + : [rightCandidate, leftCandidate, belowCandidate, aboveCandidate]; + + const pinnedRects = (typeof _getPinnedPopupsViewportRects === "function") + ? _getPinnedPopupsViewportRects() + : []; + + let chosen = null; + for (const c of candidates) { + // Clamp dans la safe area + let cx = c.x, cy = c.y; + if (cx < safe.left) cx = safe.left; + if (cx + tipW > safe.right) cx = safe.right - tipW; + if (cx < safe.left) continue; // popup plus large que safe area — skip + if (cy < safe.top) cy = safe.top; + if (cy + tipH > safe.bottom) cy = safe.bottom - tipH; + if (cy < safe.top) continue; + + // Ne chevauche PAS la source (garantit qu'on ne la cache pas) + const candRect = { left: cx, top: cy, right: cx + tipW, bottom: cy + tipH }; + if (_rectsOverlap(candRect, srcRect)) continue; + + // Ne chevauche pas les popups épinglés existants + const hitsPinned = pinnedRects.some(pr => _rectsOverlap(candRect, pr)); + if (hitsPinned) continue; + + chosen = { x: cx, y: cy, label: c.label }; + break; } - setTooltipViewportPosition(x, y); + if (!chosen) { + // Fallback : droite clampée à tout prix, même si ça chevauche (cas rare + // avec écran minuscule ou beaucoup de popups épinglés) + let fx = srcRect.right + pad; + let fy = srcRect.top; + if (fx + tipW > safe.right) fx = safe.right - tipW; + if (fx < safe.left) fx = safe.left; + if (fy + tipH > safe.bottom) fy = safe.bottom - tipH; + if (fy < safe.top) fy = safe.top; + chosen = { x: fx, y: fy, label: "fallback" }; + console.log("[positionTooltip] fallback utilisé (aucun candidat optimal trouvé)"); + } else { + console.log(`[positionTooltip] position choisie : ${chosen.label} (${Math.round(chosen.x)}, ${Math.round(chosen.y)})`); + } + + setTooltipViewportPosition(chosen.x, chosen.y); } /** @@ -7043,6 +7202,9 @@ function pinTooltip() { // Enregistrer dans la liste pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect }); + // v2026.5.34 : stocker une référence à l'iv sur l'élément DOM — utilisée + // par _softUnpinPopup pour pouvoir ré-épingler via le bouton 📌 restauré. + popup._linkedIv = iv; // v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols) bulleState.pinned = false; @@ -7106,47 +7268,53 @@ async function _refreshPinnedPopupIv(popup, iv) { } /** - * Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est - * plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on - * la laisse visible. Elle disparait quand la souris sort. + * Désépinglage "mou" (v4.3.3) : transforme un popup épinglé en tooltip simple. + * + * v2026.5.34 : refonte — le popup ne disparaît PLUS au mouseleave. À la place, + * il redevient un tooltip normal avec ses boutons ↻ (Actualiser) et 📌 + * (Épingler) restaurés dans .tooltip-actions, et reste visible à l'écran + * comme un tooltip classique. L'user peut le re-cliquer sur 📌 pour le + * ré-épingler, ou cliquer ailleurs pour s'en débarrasser normalement. + * + * @param {HTMLElement} el - le popup à désépingler */ function _softUnpinPopup(el) { - // Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM - const idx = pinnedPopups.findIndex(p => p.el === el); - if (idx >= 0) pinnedPopups.splice(idx, 1); + if (!el) { + console.warn("[softUnpin] elément null — abandon"); + return; + } + console.log("[softUnpin] désépinglage du popup", el.dataset.actionId || "(sans actionId)"); - // v2026.5.23 : reset le flag global bulleState.pinned si plus aucun popup - // épinglé. Sans ça, le tooltip live "croit" toujours qu'il est pinned et - // son handler click (notamment ↻ reload) peut partir en cacahuète. + // 1. Retirer de la liste des popups épinglés (pour Ctrl×2 etc.) + const idx = pinnedPopups.findIndex(p => p.el === el); + if (idx >= 0) { + pinnedPopups.splice(idx, 1); + console.log(`[softUnpin] retiré de pinnedPopups (reste ${pinnedPopups.length})`); + } + + // 2. Reset le flag global bulleState.pinned si plus aucun popup épinglé. + // Sans ça, le tooltip live "croit" toujours qu'il est pinned et son + // handler click (notamment ↻ reload) peut partir en cacahuète. if (pinnedPopups.length === 0) { bulleState.pinned = false; } - // v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les - // attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×, - // padding-top, etc.). La classe .soft-unpinned fait ça côté CSS. - // On retire .pinned-popup pour que les règles visuelles lourdes - // disparaissent, tout en gardant la popup au même endroit (position - // absolute conservée). - el.classList.remove("pinned-popup"); - el.classList.add("soft-unpinned"); - // Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre) - const pinBtn = el.querySelector('[data-action="pin"]'); - if (pinBtn) pinBtn.textContent = "📍"; - // Supprimer les éléments propres au mode épinglé : barre de drag et × + // 3. Retirer les éléments propres au mode épinglé : topbar, dragbar, + // close btn, et classes minimized/reduced. const dragbar = el.querySelector(".pinned-popup-dragbar"); if (dragbar) dragbar.remove(); const closeBtn = el.querySelector(".pinned-popup-close"); if (closeBtn) closeBtn.remove(); - // v2026.5.17 : retirer aussi la nouvelle topbar et le conteneur minimisé const topbar = el.querySelector(".pinned-popup-topbar"); if (topbar) topbar.remove(); el.classList.remove("pinned-popup-minimized"); el.classList.remove("pinned-popup-reduced"); - // v2026.5.18 : retirer aussi la pastille du dock si elle existe + // 4. Retirer la pastille du dock si elle existe if (el._linkedPill) { - try { el._linkedPill.remove(); } catch (e) {} + try { el._linkedPill.remove(); } catch (e) { + console.warn("[softUnpin] erreur remove pill:", e); + } el._linkedPill = null; } // Si le dock est vide, le cacher ; mettre à jour le bouton "Fermer tous" @@ -7159,13 +7327,76 @@ function _softUnpinPopup(el) { _ensureDockCloseAllBtn(); } - // v2026.5.22 : si le tooltip hover est actuellement affiché pour la même - // intervention que celle qu'on désépingle, il faut regénérer son HTML pour - // que l'icône passe de 📍 (active rouge) à 📌 (non active) — sinon l'user - // voit l'ancienne icône et croit qu'il est toujours épinglé. - // v2026.5.23 : AUSSI reset le flag iv._reloading qui pourrait rester bloqué - // à true si le bouton ↻ avait été cliqué pendant que le popup était épinglé - // sans que le finally soit atteint. + // 5. Restaurer le bouton 📌 dans .tooltip-actions + // Lors de l'épinglage, ce bouton a été SUPPRIMÉ du DOM (voir pinTooltip + // v2026.5.17 ligne 6921). Il faut le recréer pour que l'user puisse + // ré-épingler ou que l'icône 📌 revienne visible. + const actionsEl = el.querySelector('.tooltip-actions'); + if (actionsEl) { + const existingPin = actionsEl.querySelector('.tooltip-pinbtn[data-action="pin"]'); + if (!existingPin) { + console.log("[softUnpin] restauration du bouton 📌 dans .tooltip-actions"); + const pinBtn = document.createElement('div'); + pinBtn.className = 'tooltip-actionbtn tooltip-pinbtn'; + pinBtn.setAttribute('data-action', 'pin'); + pinBtn.title = 'Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.'; + pinBtn.textContent = '📌'; + actionsEl.appendChild(pinBtn); + } else { + console.log("[softUnpin] bouton 📌 déjà présent, mise à jour visuelle"); + existingPin.textContent = '📌'; + existingPin.classList.remove('tooltip-pinbtn-active'); + } + } else { + console.warn("[softUnpin] .tooltip-actions introuvable dans le popup — boutons pas restaurés"); + } + + // 5bis. v2026.5.34 : attacher un nouveau handler click sur .tooltip-actions + // du popup soft-unpinned pour ré-épinglement / actualisation. + // L'ancien handler du popup épinglé (qui faisait softUnpin au clic + // sur pin) est encore attaché, mais notre nouveau handler utilise + // stopPropagation avant pour l'empêcher de s'exécuter. + // On garde une référence à l'iv pour pouvoir ré-épingler proprement. + const ivForRepin = el._linkedIv || null; + if (actionsEl && ivForRepin) { + // Retirer d'éventuels handlers précédents en remplaçant l'element + const newActions = actionsEl.cloneNode(true); + actionsEl.parentNode.replaceChild(newActions, actionsEl); + + newActions.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + e.stopPropagation(); + e.preventDefault(); + + if (action === "pin") { + console.log("[softUnpin] clic sur 📌 d'un popup soft-unpinned → ré-épinglage"); + // Retirer le popup soft-unpinned actuel + try { el.remove(); } catch (err) {} + // Simuler le survol de l'iv pour rendre le tooltip live, puis épingler + state.currentTooltipIv = ivForRepin; + pinTooltip(); + } else if (action === "reload") { + console.log("[softUnpin] clic sur ↻ d'un popup soft-unpinned → recharger intervention"); + if (typeof reloadSingleIntervention === "function") { + reloadSingleIntervention(ivForRepin, btn); + } else { + console.warn("[softUnpin] reloadSingleIntervention indisponible"); + } + } + }); + } + + // 6. Basculer visuellement : retirer pinned-popup, mettre soft-unpinned. + // La classe .soft-unpinned applique les styles "tooltip normal" + // (pas de bordure bleue, pas de padding-top pour topbar disparue, etc.) + el.classList.remove("pinned-popup"); + el.classList.add("soft-unpinned"); + + // 7. Régénérer le tooltip hover si on est en train de survoler la même iv + // (pour synchroniser l'icône 📌/📍 dans le tooltip live). + // Aussi, reset iv._reloading qui pourrait rester bloqué à true. const tip = tooltipEl(); if (tip && tip.classList.contains("visible") && state.currentTooltipIv) { try { @@ -7176,19 +7407,43 @@ function _softUnpinPopup(el) { } } - // Helper qui joue l'animation de sortie puis supprime le DOM - const animateAndRemove = () => { - el.classList.add("unpinning"); - setTimeout(() => el.remove(), 180); - }; + // 8. v2026.5.34 : ne PAS programmer de suppression automatique au mouseleave. + // Le popup reste visible comme un tooltip normal. L'user le ferme en + // cliquant ailleurs ou en appuyant sur Échap. Il peut aussi re-cliquer + // sur 📌 pour le ré-épingler. + // + // Pour que le popup disparaisse quand l'user clique ailleurs, on ajoute + // un handler de clic document qui supprime tous les .soft-unpinned si + // le clic est hors du popup. Ce handler ne s'arme QU'UNE FOIS (au premier + // désépinglage) et reste globalement. + _ensureSoftUnpinnedCleanupHandler(); - if (!el.matches(":hover")) { - animateAndRemove(); - return; - } - // Souris dessus : on ne supprime pas tout de suite. On attend mouseleave - // et à ce moment on joue l'animation de sortie et on supprime. - el.addEventListener("mouseleave", animateAndRemove, { once: true }); + console.log("[softUnpin] terminé — popup reste visible en mode tooltip normal"); +} + +/** + * v2026.5.34 : pose un handler global (une seule fois) qui ferme les popups + * en état .soft-unpinned quand l'user clique ailleurs, ou appuie sur Échap. + */ +let _softUnpinnedCleanupHandlerInstalled = false; +function _ensureSoftUnpinnedCleanupHandler() { + if (_softUnpinnedCleanupHandlerInstalled) return; + _softUnpinnedCleanupHandlerInstalled = true; + + // Clic hors d'un popup soft-unpinned → supprimer + document.addEventListener("mousedown", (e) => { + const softPopups = document.querySelectorAll(".soft-unpinned"); + if (softPopups.length === 0) return; + softPopups.forEach(el => { + // Ne pas fermer si le clic est DANS le popup lui-même + if (el.contains(e.target)) return; + console.log("[softUnpin cleanup] clic hors du popup — suppression"); + el.classList.add("unpinning"); + setTimeout(() => { try { el.remove(); } catch (err) {} }, 180); + }); + }, true); // phase capture pour attraper avant d'autres handlers + + console.log("[softUnpin] handler cleanup global installé"); } // ============================================================================