From 3d5bdbab3d60c05cbcee8da0d13518917a668dd2 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Thu, 23 Apr 2026 14:48:16 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.20=20=E2=80=94=20Safe=20area=20:=20popu?= =?UTF-8?q?ps=20jamais=20cach=C3=A9s=20sous=20topbar/dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- viewer.css | 59 ++++++++- viewer.js | 342 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 363 insertions(+), 40 deletions(-) diff --git a/manifest.json b/manifest.json index c085ee8..ddc2bc2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.19", + "version": "2026.5.20", "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.css b/viewer.css index 569f1c3..58044e9 100644 --- a/viewer.css +++ b/viewer.css @@ -2793,8 +2793,15 @@ header.topbar::before { font-size: 14px; line-height: 1; } -.pinned-popup-refresh.spinning { +.pinned-popup-refresh svg { + width: 14px; + height: 14px; +} +.pinned-popup-refresh.spinning svg { animation: pinned-popup-refresh-spin 0.6s linear infinite; + transform-origin: 50% 50%; +} +.pinned-popup-refresh.spinning { pointer-events: none; } @keyframes pinned-popup-refresh-spin { @@ -2834,3 +2841,53 @@ body.popup-dragging .pinned-popup { opacity: 0.85; font-family: var(--mono, monospace); } + +/* ========================================================================== + v2026.5.20 : mini-menu au survol d'une pastille dock + ========================================================================== */ +.pill-hover-menu { + position: fixed; + z-index: 60; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 130px; + animation: pill-hover-menu-appear 0.12s ease-out; +} +@keyframes pill-hover-menu-appear { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.pill-hover-menu-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: transparent; + color: var(--text); + border: none; + border-radius: 4px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background 0.12s; +} +.pill-hover-menu-btn:hover { + background: var(--bg-muted); +} +.pill-hover-menu-btn.pill-hover-menu-close:hover { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} +.pill-menu-ico { + font-size: 14px; + width: 16px; + text-align: center; +} diff --git a/viewer.js b/viewer.js index f4e457f..84b8a77 100644 --- a/viewer.js +++ b/viewer.js @@ -565,18 +565,65 @@ function bindTopbar() { } } }); + // v2026.5.20 : nouveau comportement de la touche Échap + // - Appui court : ferme uniquement le popup SOUS la souris (normal ou + // minimisé). Si la souris n'est sur aucun popup, ne fait rien. + // Ferme aussi le popup user-badge et la grande bulle anchored. + // - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde + // les pastilles dock (popups "réduits" en bas). + let _escHoldTimer = null; + let _escHoldTriggered = false; + const ESC_HOLD_MS = 3000; + document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - hideUserNamePopup(); - // v4.2.4 : Échap ferme aussi la grande bulle anchored - const tip = tooltipEl(); - if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { - hideTooltip({ force: true }); - } - // v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer) - if (typeof closeAllPinnedPopups === "function") { - closeAllPinnedPopups(); + if (e.key !== "Escape") return; + // keydown peut se répéter si la touche est maintenue ; on ignore les répétitions. + if (e.repeat) return; + // Armer le timer "maintenu 3s" + _escHoldTriggered = false; + if (_escHoldTimer) clearTimeout(_escHoldTimer); + _escHoldTimer = setTimeout(() => { + _escHoldTriggered = true; + _escHoldTimer = null; + // Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés + document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => { + try { p.remove(); } catch (err) {} + }); + // Nettoyer la liste + for (let i = pinnedPopups.length - 1; i >= 0; i--) { + if (!document.body.contains(pinnedPopups[i].el)) { + pinnedPopups.splice(i, 1); + } } + _ensureDockCloseAllBtn(); + }, ESC_HOLD_MS); + }); + + document.addEventListener("keyup", (e) => { + if (e.key !== "Escape") return; + if (_escHoldTimer) { + clearTimeout(_escHoldTimer); + _escHoldTimer = null; + } + if (_escHoldTriggered) { + // On a déjà fait l'action "maintenu", ne rien faire de plus + _escHoldTriggered = false; + return; + } + // Appui court : fermer le popup sous la souris si applicable + hideUserNamePopup(); + const tip = tooltipEl(); + if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { + hideTooltip({ force: true }); + } + // Quel popup est sous la souris ? Utiliser :hover pour détecter + const hovered = document.querySelector(".pinned-popup:hover"); + if (hovered && !hovered.classList.contains("pinned-popup-reduced")) { + // Retirer aussi de pinnedPopups + const idx = pinnedPopups.findIndex(p => p.el === hovered); + if (idx >= 0) pinnedPopups.splice(idx, 1); + hovered.remove(); + _ensureDockCloseAllBtn(); } }); @@ -6399,31 +6446,41 @@ function _rectsOverlap(a, b) { function _findFreePopupPosition(rowEl, w, h) { const pad = 14; const rowRect = rowEl.getBoundingClientRect(); - const viewportW = window.innerWidth; - const viewportH = window.innerHeight; + // v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock) + const safe = _getPopupSafeArea(); - // 4 candidats, en coords viewport + // 4 candidats d'abord, autour de la row source (en coords viewport) const candidates = [ - // Droite - { x: rowRect.right + pad, y: rowRect.top, name: "droite" }, - // Gauche - { x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, - // Dessous - { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, - // Dessus - { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } + { x: rowRect.right + pad, y: rowRect.top, name: "droite" }, + { x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, + { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, + { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } ]; - // Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir - // en coord document, puis tester le chevauchement + // v2026.5.20 : ajouter une grille de positions de fallback couvrant toute + // la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une + // place, sauf si vraiment trop de popups actifs. + const availW = safe.right - safe.left; + const availH = safe.bottom - safe.top; + if (availW > w + 20 && availH > h + 20) { + for (let y = safe.top; y + h <= safe.bottom; y += 60) { + for (let x = safe.left; x + w <= safe.right; x += 60) { + candidates.push({ x, y, name: "grid" }); + } + } + } + + // Tester chaque candidat dans l'ordre for (const c of candidates) { let x = c.x, y = c.y; - // Clamp horizontal dans le viewport - if (x < 4) x = 4; - if (x + w > viewportW - 8) x = viewportW - 8 - w; - // Clamp vertical dans le viewport - if (y < 4) y = 4; - if (y + h > viewportH - 8) y = viewportH - 8 - h; + // Clamp dans la safe area + if (x < safe.left) x = safe.left; + if (x + w > safe.right) x = safe.right - w; + if (x < safe.left) continue; // popup plus large que safe area + if (y < safe.top) y = safe.top; + if (y + h > safe.bottom) y = safe.bottom - h; + if (y < safe.top) continue; + // Si, après clamp, la popup chevaucherait la ligne source elle-même, // on ignore ce candidat (on préfère une direction qui la laisse visible). const rowRectClamped = { @@ -6431,9 +6488,12 @@ function _findFreePopupPosition(rowEl, w, h) { right: rowRect.right, bottom: rowRect.bottom }; const candRect = { left: x, top: y, right: x + w, bottom: y + h }; - if (_rectsOverlap(candRect, rowRectClamped)) continue; + // v2026.5.20 : pour les 4 candidats principaux, on refuse de chevaucher + // la row ; pour les candidats "grid" de fallback, on l'accepte + // (on veut une place à tout prix). + if (c.name !== "grid" && _rectsOverlap(candRect, rowRectClamped)) continue; - // Test chevauchement avec les popups déjà épinglés + // Test chevauchement avec les popups déjà épinglés (coords document) const docRect = { left: _viewportToDocumentX(x), top: _viewportToDocumentY(y), @@ -6442,13 +6502,14 @@ function _findFreePopupPosition(rowEl, w, h) { }; let overlapsOther = false; for (const p of pinnedPopups) { + // Ne pas comparer avec un popup qui est dans le dock (réduit) + if (p.el && p.el.classList && p.el.classList.contains("pinned-popup-reduced")) continue; if (_rectsOverlap(docRect, p.rect)) { overlapsOther = true; break; } } if (!overlapsOther) { - // Position libre trouvée return { viewportX: x, viewportY: y, docX: docRect.left, docY: docRect.top, @@ -6456,6 +6517,30 @@ function _findFreePopupPosition(rowEl, w, h) { }; } } + + // v2026.5.20 : ultime fallback — accepter de chevaucher mais décaler + // un peu par rapport au 1er popup épinglé existant. Évite complètement + // le "Pas de place" injuste. + if (pinnedPopups.length > 0) { + const last = pinnedPopups[pinnedPopups.length - 1]; + let x = (last.rect.left - (window.scrollX || 0)) + 30; + let y = (last.rect.top - (window.scrollY || 0)) + 30; + if (x + w > safe.right) x = safe.right - w; + if (y + h > safe.bottom) y = safe.bottom - h; + if (x < safe.left) x = safe.left; + if (y < safe.top) y = safe.top; + const docRect = { + left: _viewportToDocumentX(x), + top: _viewportToDocumentY(y), + right: _viewportToDocumentX(x + w), + bottom: _viewportToDocumentY(y + h) + }; + return { + viewportX: x, viewportY: y, + docX: docRect.left, docY: docRect.top, + rect: docRect + }; + } return null; } @@ -6530,13 +6615,13 @@ function pinTooltip() { }); topbar.appendChild(minBtn); - // v2026.5.19 : Bouton Actualiser (icône ↻) + // v2026.5.19 : Bouton Actualiser (même icône SVG que le tooltip standard) // Re-fetch la fiche de l'intervention pour mettre à jour les infos (statut, // commentaires, action text) sans recharger le planning entier. const refreshBtn = document.createElement("button"); refreshBtn.type = "button"; refreshBtn.className = "pinned-popup-btn pinned-popup-refresh"; - refreshBtn.innerHTML = "↻"; + refreshBtn.innerHTML = ''; refreshBtn.title = "Actualiser les informations de cette intervention"; refreshBtn.addEventListener("click", async (e) => { e.stopPropagation(); @@ -6616,6 +6701,9 @@ function pinTooltip() { popup.style.top = pos.docY + "px"; popup.style.visibility = "visible"; + // v2026.5.20 : clamper dans la safe area (topbar + dock) + _clampPopupInSafeArea(popup); + // Enregistrer dans la liste pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect }); @@ -6885,11 +6973,189 @@ function _reducePinnedPopup(popup) { _restorePinnedPopupFromDock(popup); }); + // v2026.5.20 : mini-menu au survol (Agrandir / Fermer) + pill.addEventListener("mouseenter", () => { + _showPillHoverMenu(pill, popup); + }); + pill.addEventListener("mouseleave", (e) => { + // Le menu peut être sous la souris — on ne ferme pas si on entre dans le menu + _schedulePillMenuClose(); + }); + dock.appendChild(pill); dock.classList.add("visible"); // v2026.5.18 : s'assurer qu'il y a un bouton "Fermer tous" si 2+ popups _ensureDockCloseAllBtn(); + + // v2026.5.20 : le dock qui apparaît peut chevaucher des popups flottants — + // les reclamper pour qu'ils restent dans la safe area. + _reclampAllFloatingPopups(); +} + +/** + * v2026.5.20 : affiche un mini-menu au-dessus d'une pastille dock au survol. + * Contient 2 actions : Agrandir, Fermer. + */ +let _pillMenuCloseTimer = null; +function _showPillHoverMenu(pill, popup) { + // Annuler une fermeture en cours + if (_pillMenuCloseTimer) { + clearTimeout(_pillMenuCloseTimer); + _pillMenuCloseTimer = null; + } + // S'il existe déjà un menu pour un autre pill, le fermer + const existing = document.getElementById("pill-hover-menu"); + if (existing) { + if (existing._linkedPill === pill) return; // déjà pour ce pill + existing.remove(); + } + + const menu = document.createElement("div"); + menu.id = "pill-hover-menu"; + menu.className = "pill-hover-menu"; + menu._linkedPill = pill; + menu._linkedPopup = popup; + + const restoreBtn = document.createElement("button"); + restoreBtn.type = "button"; + restoreBtn.className = "pill-hover-menu-btn"; + restoreBtn.innerHTML = ' Agrandir'; + restoreBtn.addEventListener("click", (e) => { + e.stopPropagation(); + _hidePillHoverMenu(); + _restorePinnedPopupFromDock(popup); + }); + menu.appendChild(restoreBtn); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "pill-hover-menu-btn pill-hover-menu-close"; + closeBtn.innerHTML = ' Fermer'; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + _hidePillHoverMenu(); + // Retirer le popup de la liste et supprimer le DOM + const idx = pinnedPopups.findIndex(p => p.el === popup); + if (idx >= 0) pinnedPopups.splice(idx, 1); + try { popup.remove(); } catch (err) {} + try { pill.remove(); } catch (err) {} + const dock = document.getElementById("pinned-popups-dock"); + if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) { + dock.classList.remove("visible"); + const closeAllBtn = document.getElementById("pinned-popups-close-all"); + if (closeAllBtn) closeAllBtn.remove(); + _reclampAllFloatingPopups(); + } else { + _ensureDockCloseAllBtn(); + } + }); + menu.appendChild(closeBtn); + + document.body.appendChild(menu); + + // Positionner au-dessus de la pastille + const r = pill.getBoundingClientRect(); + const menuR = menu.getBoundingClientRect(); + let left = r.left + (r.width / 2) - (menuR.width / 2); + if (left < 4) left = 4; + if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4; + menu.style.left = left + "px"; + menu.style.top = (r.top - menuR.height - 8) + "px"; + + // Garder ouvert si la souris entre dans le menu + menu.addEventListener("mouseenter", () => { + if (_pillMenuCloseTimer) { + clearTimeout(_pillMenuCloseTimer); + _pillMenuCloseTimer = null; + } + }); + menu.addEventListener("mouseleave", () => { + _schedulePillMenuClose(); + }); +} + +function _schedulePillMenuClose() { + if (_pillMenuCloseTimer) clearTimeout(_pillMenuCloseTimer); + _pillMenuCloseTimer = setTimeout(() => { + _hidePillHoverMenu(); + _pillMenuCloseTimer = null; + }, 250); +} + +function _hidePillHoverMenu() { + const existing = document.getElementById("pill-hover-menu"); + if (existing) existing.remove(); +} + +/** + * v2026.5.20 : calcule la safe area pour les popups épinglés. + * Retourne {top, bottom, left, right} en coords viewport. + * - top : hauteur de la topbar (les popups ne doivent pas passer dessous) + * - bottom : top du dock si visible, sinon hauteur viewport + */ +function _getPopupSafeArea() { + let topLimit = 4; + const topbar = document.querySelector("header.topbar"); + if (topbar) { + const r = topbar.getBoundingClientRect(); + if (r.bottom > topLimit) topLimit = r.bottom + 4; + } + let bottomLimit = window.innerHeight - 4; + const dock = document.getElementById("pinned-popups-dock"); + if (dock && dock.classList.contains("visible")) { + const r = dock.getBoundingClientRect(); + if (r.top < bottomLimit) bottomLimit = r.top - 4; + } + return { top: topLimit, bottom: bottomLimit, left: 4, right: window.innerWidth - 4 }; +} + +/** + * v2026.5.20 : contraint un popup flottant (en coords document via style.left/top) + * dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock + * apparaît/disparaît. + */ +function _clampPopupInSafeArea(popup) { + if (!popup) return; + if (popup.classList.contains("pinned-popup-reduced")) return; // pas clamp si docké + const safe = _getPopupSafeArea(); + const rect = popup.getBoundingClientRect(); + const w = rect.width || popup.offsetWidth || 280; + const h = rect.height || popup.offsetHeight || 200; + + // Les coords viewport actuelles + const vLeft = rect.left; + const vTop = rect.top; + + // Calcul des coords viewport cibles après clamp + let newVLeft = vLeft; + let newVTop = vTop; + if (newVLeft < safe.left) newVLeft = safe.left; + if (newVLeft + w > safe.right) newVLeft = safe.right - w; + if (newVLeft < safe.left) newVLeft = safe.left; // si popup plus large que viewport + + if (newVTop < safe.top) newVTop = safe.top; + if (newVTop + h > safe.bottom) newVTop = safe.bottom - h; + if (newVTop < safe.top) newVTop = safe.top; + + if (newVLeft === vLeft && newVTop === vTop) return; // rien à faire + + // Différence = appliquer au style.left / style.top (qui sont en document coords) + const dx = newVLeft - vLeft; + const dy = newVTop - vTop; + const curLeft = parseFloat(popup.style.left) || 0; + const curTop = parseFloat(popup.style.top) || 0; + popup.style.left = (curLeft + dx) + "px"; + popup.style.top = (curTop + dy) + "px"; +} + +/** + * Réclampe tous les popups flottants (utile après apparition/disparition du dock). + */ +function _reclampAllFloatingPopups() { + document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => { + _clampPopupInSafeArea(p); + }); } /** @@ -7075,12 +7341,12 @@ function _attachPopupDragHandler(popup, dragbar) { let newLeft = startLeft + dx; let newTop = startTop + dy; - // Clamper dans le document (pas sortir trop à gauche/haut) - if (newLeft < 4) newLeft = 4; - if (newTop < 4) newTop = 4; - + // v2026.5.20 : clamper dans la safe area (topbar en haut, dock en bas, + // bordures viewport gauche/droite). On calcule en coords viewport puis + // on applique en coords document. popup.style.left = newLeft + "px"; popup.style.top = newTop + "px"; + _clampPopupInSafeArea(popup); }; const onMouseUp = () => {