forked from FroSteel/Planification
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6d549d522 |
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Planification",
|
"name": "Planification",
|
||||||
"version": "4.2.8",
|
"version": "4.3.0",
|
||||||
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).",
|
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.3.0 : (1) conflits horaires entre interventions d'un même tech affichés en rouge + ⚠. (2) Réservations disparues retirées directement (pas de re-fetch inutile). (3) Popups épinglés détachés : plusieurs peuvent coexister, ancrés au contenu (scrollent avec la page), auto-positionnés sans se marcher dessus (toast si pas de place), Échap pour tout fermer, Ctrl×2 pour fermer si un seul épinglé. Inclut v4.2.9.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"scripting",
|
"scripting",
|
||||||
|
|||||||
+91
@@ -1751,3 +1751,94 @@ html, body {
|
|||||||
.modal-actions.horizontal .btn {
|
.modal-actions.horizontal .btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
v4.2.9 : blocage du scroll arrière quand une modal est ouverte.
|
||||||
|
La classe body.modal-open est ajoutée/retirée automatiquement par
|
||||||
|
initModalScrollLock() dans viewer.js dès qu'un .modal-overlay existe.
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */
|
||||||
|
.app-footer {
|
||||||
|
position: fixed;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint, #8892a0);
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none; /* ne capture pas les clics */
|
||||||
|
user-select: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
z-index: 1; /* sous les modals (qui sont à 10000) */
|
||||||
|
}
|
||||||
|
.app-footer:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
v4.3.0 : conflit d'horaire entre 2 interventions d'un même tech.
|
||||||
|
Les heures s'affichent en rouge + icône ⚠ à côté.
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
.iv-time-vertical.iv-time-overlap .iv-time-start,
|
||||||
|
.iv-time-vertical.iv-time-overlap .iv-time-end,
|
||||||
|
.iv-time-vertical.iv-time-overlap .iv-time-arrow {
|
||||||
|
color: var(--danger, #b03030) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.iv-time-overlap-warn {
|
||||||
|
color: var(--danger, #b03030);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
cursor: help;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
v4.3.0 : popups épinglés détachés
|
||||||
|
Ancrés au contenu (position:absolute coord document) → scrollent avec
|
||||||
|
la page. Persistent jusqu'à fermeture explicite.
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
.tooltip.pinned-popup {
|
||||||
|
position: absolute !important; /* override le fixed du .tooltip */
|
||||||
|
z-index: 500; /* au-dessus du contenu, sous les modals (10000) */
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
/* Bordure plus visible pour distinguer du tooltip live */
|
||||||
|
border: 2px solid var(--accent, #0f4f8b);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
|
||||||
|
/* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */
|
||||||
|
animation: pinned-popup-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pinned-popup-in {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton × de fermeture du popup épinglé */
|
||||||
|
.pinned-popup-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.pinned-popup-close:hover {
|
||||||
|
background: var(--danger-soft, #fbe6e6);
|
||||||
|
color: var(--danger, #b03030);
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ async function init() {
|
|||||||
initTheme();
|
initTheme();
|
||||||
bindTopbar();
|
bindTopbar();
|
||||||
bindTooltipInteractions();
|
bindTooltipInteractions();
|
||||||
|
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
|
||||||
|
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
|
||||||
|
|
||||||
// Initialiser la date = aujourd'hui
|
// Initialiser la date = aujourd'hui
|
||||||
state.currentDate = todayISO();
|
state.currentDate = todayISO();
|
||||||
@@ -458,6 +460,10 @@ function bindTopbar() {
|
|||||||
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
|
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
|
||||||
hideTooltip({ force: true });
|
hideTooltip({ force: true });
|
||||||
}
|
}
|
||||||
|
// v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
|
||||||
|
if (typeof closeAllPinnedPopups === "function") {
|
||||||
|
closeAllPinnedPopups();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -675,6 +681,45 @@ function showAlertModal(opts) {
|
|||||||
document.addEventListener("keydown", escHandler);
|
document.addEventListener("keydown", escHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// Un MutationObserver surveille l'apparition/disparition de tout élément
|
||||||
|
// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la
|
||||||
|
// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier
|
||||||
|
// modal disparaît, la classe est retirée.
|
||||||
|
//
|
||||||
|
// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent
|
||||||
|
// sans modification individuelle.
|
||||||
|
|
||||||
|
function initModalScrollLock() {
|
||||||
|
const updateLock = () => {
|
||||||
|
const hasModal = document.querySelector(".modal-overlay") !== null;
|
||||||
|
document.body.classList.toggle("modal-open", hasModal);
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(updateLock);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: false });
|
||||||
|
updateLock(); // au cas où un modal serait déjà là au boot
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4.2.9 : pied de page discret "QRO / Mois Année / vX.X.X" en bas à droite.
|
||||||
|
// La version est lue depuis le manifest (source unique de vérité).
|
||||||
|
function initAppFooter() {
|
||||||
|
if (document.querySelector(".app-footer")) return;
|
||||||
|
let version = "";
|
||||||
|
try {
|
||||||
|
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
|
||||||
|
? chrome.runtime.getManifest() : null;
|
||||||
|
if (manifest && manifest.version) version = "v" + manifest.version;
|
||||||
|
} catch (e) {}
|
||||||
|
const dateStr = "Avril 26"; // mois/année de release de cette itération
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "app-footer";
|
||||||
|
el.textContent = `QRO / ${dateStr}${version ? " / " + version : ""}`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v4.2.6 : Modals Absence et Douchette
|
// v4.2.6 : Modals Absence et Douchette
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1980,6 +2025,17 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken)
|
|||||||
* et iv._disappearRemove (true si à retirer).
|
* et iv._disappearRemove (true si à retirer).
|
||||||
*/
|
*/
|
||||||
async function analyzeOneDisappearedIv(tech, iv) {
|
async function analyzeOneDisappearedIv(tech, iv) {
|
||||||
|
// v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont
|
||||||
|
// pas de notion de "terminé par tech" ni de statut clos/résolu à afficher
|
||||||
|
// (pas de fiche à ouvrir). Quand une réservation disparaît du planning,
|
||||||
|
// elle est juste retirée — inutile de re-fetcher sa fiche.
|
||||||
|
if (iv.type === "AL-Reservation") {
|
||||||
|
iv._disappearChecking = false;
|
||||||
|
iv._disappearStatus = "cancelled";
|
||||||
|
iv._disappearRemove = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Étape 1 : re-fetch la fiche
|
// Étape 1 : re-fetch la fiche
|
||||||
const resp = await sendMessage({
|
const resp = await sendMessage({
|
||||||
type: "fetchFiche",
|
type: "fetchFiche",
|
||||||
@@ -2933,6 +2989,10 @@ function renderFromData(data) {
|
|||||||
document.getElementById("session-needed").classList.add("hidden");
|
document.getElementById("session-needed").classList.add("hidden");
|
||||||
document.getElementById("cards").classList.remove("hidden");
|
document.getElementById("cards").classList.remove("hidden");
|
||||||
|
|
||||||
|
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
|
||||||
|
// tech (même heure de début OU chevauchement).
|
||||||
|
detectOverlaps(data.techs);
|
||||||
|
|
||||||
// Calculer les stats
|
// Calculer les stats
|
||||||
const stats = computeStats(data.techs, data.targetDate);
|
const stats = computeStats(data.techs, data.targetDate);
|
||||||
renderCaptureInfo(data, stats);
|
renderCaptureInfo(data, stats);
|
||||||
@@ -2940,6 +3000,51 @@ function renderFromData(data) {
|
|||||||
renderCards(data);
|
renderCards(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
|
||||||
|
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
|
||||||
|
// autre (même heure de début OU chevauchement de créneaux).
|
||||||
|
// Les absences récurrentes, tickets fantômes à retirer, et réservations
|
||||||
|
// sont ignorés (pas de conflit pertinent pour eux).
|
||||||
|
function detectOverlaps(techs) {
|
||||||
|
if (!techs) return;
|
||||||
|
for (const tech of techs) {
|
||||||
|
const ivs = (tech.interventions || []).filter(iv =>
|
||||||
|
iv && iv.startTime && iv.endTime &&
|
||||||
|
!iv._disappearRemove &&
|
||||||
|
iv.type !== "AL-Reservation"
|
||||||
|
);
|
||||||
|
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
|
||||||
|
for (const iv of (tech.interventions || [])) {
|
||||||
|
iv._hasOverlap = false;
|
||||||
|
}
|
||||||
|
// Convertir HH:MM en minutes pour comparaison rapide
|
||||||
|
const toMin = (hhmm) => {
|
||||||
|
if (!hhmm) return null;
|
||||||
|
const parts = hhmm.split(":");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
const h = parseInt(parts[0], 10);
|
||||||
|
const m = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(h) || isNaN(m)) return null;
|
||||||
|
return h * 60 + m;
|
||||||
|
};
|
||||||
|
// Comparer chaque paire
|
||||||
|
for (let i = 0; i < ivs.length; i++) {
|
||||||
|
for (let j = i + 1; j < ivs.length; j++) {
|
||||||
|
const a = ivs[i], b = ivs[j];
|
||||||
|
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
|
||||||
|
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
|
||||||
|
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
|
||||||
|
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
|
||||||
|
// Inclut aussi le cas "même heure de début" (aStart === bStart).
|
||||||
|
if (aStart < bEnd && bStart < aEnd) {
|
||||||
|
a._hasOverlap = true;
|
||||||
|
b._hasOverlap = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCaptureInfo(data, stats) {
|
function renderCaptureInfo(data, stats) {
|
||||||
const info = document.getElementById("capture-info");
|
const info = document.getElementById("capture-info");
|
||||||
if (refreshCounter > 0) {
|
if (refreshCounter > 0) {
|
||||||
@@ -3663,6 +3768,10 @@ function buildInterventionRow(iv, cardEl) {
|
|||||||
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
|
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
|
||||||
const timeEl = document.createElement("div");
|
const timeEl = document.createElement("div");
|
||||||
timeEl.className = "iv-time-vertical";
|
timeEl.className = "iv-time-vertical";
|
||||||
|
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
|
||||||
|
if (iv._hasOverlap) {
|
||||||
|
timeEl.classList.add("iv-time-overlap");
|
||||||
|
}
|
||||||
if (iv.startTime && iv.endTime) {
|
if (iv.startTime && iv.endTime) {
|
||||||
const s = document.createElement("div");
|
const s = document.createElement("div");
|
||||||
s.className = "iv-time-start";
|
s.className = "iv-time-start";
|
||||||
@@ -3676,6 +3785,14 @@ function buildInterventionRow(iv, cardEl) {
|
|||||||
timeEl.appendChild(s);
|
timeEl.appendChild(s);
|
||||||
timeEl.appendChild(sep);
|
timeEl.appendChild(sep);
|
||||||
timeEl.appendChild(e);
|
timeEl.appendChild(e);
|
||||||
|
// v4.3.0 : icône d'alerte à côté des heures si conflit
|
||||||
|
if (iv._hasOverlap) {
|
||||||
|
const warn = document.createElement("div");
|
||||||
|
warn.className = "iv-time-overlap-warn";
|
||||||
|
warn.textContent = "⚠";
|
||||||
|
warn.title = "Conflit d'horaire avec une autre intervention";
|
||||||
|
timeEl.appendChild(warn);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
timeEl.textContent = "—";
|
timeEl.textContent = "—";
|
||||||
}
|
}
|
||||||
@@ -4799,13 +4916,207 @@ function positionTooltipAnchored(rowEl) {
|
|||||||
setTooltipViewportPosition(x, y);
|
setTooltipViewportPosition(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
|
// ============================================================================
|
||||||
// qui change le curseur (text) et autorise la sélection.
|
// v4.3.0 : système de popups épinglés détachés
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
|
||||||
|
// infos au survol), on clone son contenu en un popup indépendant :
|
||||||
|
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
|
||||||
|
// document) → scrolle avec le contenu, pas avec le viewport.
|
||||||
|
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
|
||||||
|
// ait plus de place disponible).
|
||||||
|
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
|
||||||
|
//
|
||||||
|
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
|
||||||
|
|
||||||
|
const pinnedPopups = []; // [{el, iv, rect}]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
|
||||||
|
* obtenir une position absolute document, qui scrolle avec le contenu.
|
||||||
|
*/
|
||||||
|
function _viewportToDocumentY(y) {
|
||||||
|
return y + (window.scrollY || window.pageYOffset || 0);
|
||||||
|
}
|
||||||
|
function _viewportToDocumentX(x) {
|
||||||
|
return x + (window.scrollX || window.pageXOffset || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste si un rectangle {left, top, right, bottom} (en coords document)
|
||||||
|
* chevauche avec un popup déjà épinglé.
|
||||||
|
*/
|
||||||
|
function _rectsOverlap(a, b) {
|
||||||
|
return !(a.right <= b.left || a.left >= b.right ||
|
||||||
|
a.bottom <= b.top || a.top >= b.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche une position libre pour un popup de dimensions {w, h} près de la
|
||||||
|
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
|
||||||
|
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
|
||||||
|
*/
|
||||||
|
function _findFreePopupPosition(rowEl, w, h) {
|
||||||
|
const pad = 14;
|
||||||
|
const rowRect = rowEl.getBoundingClientRect();
|
||||||
|
const viewportW = window.innerWidth;
|
||||||
|
const viewportH = window.innerHeight;
|
||||||
|
|
||||||
|
// 4 candidats, 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" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
|
||||||
|
// en coord document, puis tester le chevauchement
|
||||||
|
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;
|
||||||
|
// 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 = {
|
||||||
|
left: rowRect.left, top: rowRect.top,
|
||||||
|
right: rowRect.right, bottom: rowRect.bottom
|
||||||
|
};
|
||||||
|
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
|
||||||
|
if (_rectsOverlap(candRect, rowRectClamped)) continue;
|
||||||
|
|
||||||
|
// Test chevauchement avec les popups déjà épinglés
|
||||||
|
const docRect = {
|
||||||
|
left: _viewportToDocumentX(x),
|
||||||
|
top: _viewportToDocumentY(y),
|
||||||
|
right: _viewportToDocumentX(x + w),
|
||||||
|
bottom: _viewportToDocumentY(y + h)
|
||||||
|
};
|
||||||
|
let overlapsOther = false;
|
||||||
|
for (const p of pinnedPopups) {
|
||||||
|
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,
|
||||||
|
rect: docRect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
|
||||||
|
* ancré au contenu. Le tooltip live redevient disponible.
|
||||||
|
*/
|
||||||
function pinTooltip() {
|
function pinTooltip() {
|
||||||
if (!state.currentTooltipIv) return;
|
if (!state.currentTooltipIv) return;
|
||||||
bulleState.pinned = true;
|
const srcEl = tooltipEl();
|
||||||
const el = tooltipEl();
|
if (!srcEl) return;
|
||||||
el.classList.add("pinned");
|
const iv = state.currentTooltipIv;
|
||||||
|
|
||||||
|
// Chercher la ligne source (row iv-v2)
|
||||||
|
let rowEl = null;
|
||||||
|
if (iv.actionId) {
|
||||||
|
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
|
||||||
|
}
|
||||||
|
if (!rowEl) {
|
||||||
|
// Fallback : utiliser la position actuelle du tooltip live
|
||||||
|
rowEl = srcEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloner le contenu du tooltip actuel en popup détaché
|
||||||
|
const popup = document.createElement("div");
|
||||||
|
popup.className = "tooltip pinned-popup visible";
|
||||||
|
popup.dataset.actionId = iv.actionId || "";
|
||||||
|
popup.innerHTML = srcEl.innerHTML;
|
||||||
|
|
||||||
|
// Ajouter un bouton × de fermeture (en plus du 📌)
|
||||||
|
const closeBtn = document.createElement("button");
|
||||||
|
closeBtn.type = "button";
|
||||||
|
closeBtn.className = "pinned-popup-close";
|
||||||
|
closeBtn.innerHTML = "×";
|
||||||
|
closeBtn.title = "Fermer";
|
||||||
|
closeBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_closePinnedPopup(popup);
|
||||||
|
});
|
||||||
|
popup.appendChild(closeBtn);
|
||||||
|
|
||||||
|
// Placer en (0,0) temporairement pour mesurer la taille
|
||||||
|
popup.style.position = "absolute";
|
||||||
|
popup.style.left = "-9999px";
|
||||||
|
popup.style.top = "-9999px";
|
||||||
|
popup.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
|
||||||
|
// Mesurer après rendu
|
||||||
|
const pRect = popup.getBoundingClientRect();
|
||||||
|
const w = pRect.width;
|
||||||
|
const h = pRect.height;
|
||||||
|
|
||||||
|
// Chercher une position libre
|
||||||
|
const pos = _findFreePopupPosition(rowEl, w, h);
|
||||||
|
|
||||||
|
if (!pos) {
|
||||||
|
// Pas de place : retirer et afficher un toast
|
||||||
|
popup.remove();
|
||||||
|
showToast("Pas de place", "Fermez une popup épinglée");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer la position (coords document = position: absolute)
|
||||||
|
popup.style.left = pos.docX + "px";
|
||||||
|
popup.style.top = pos.docY + "px";
|
||||||
|
popup.style.visibility = "visible";
|
||||||
|
|
||||||
|
// Enregistrer dans la liste
|
||||||
|
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
|
||||||
|
|
||||||
|
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
|
||||||
|
bulleState.pinned = false;
|
||||||
|
bulleState.hoveredInRow = false;
|
||||||
|
bulleState.hoveredInBulle = false;
|
||||||
|
srcEl.classList.remove("visible", "pinned");
|
||||||
|
srcEl.classList.add("hidden");
|
||||||
|
if (srcEl.dataset) delete srcEl.dataset.mode;
|
||||||
|
state.currentTooltipIv = null;
|
||||||
|
currentTooltipPos = null;
|
||||||
|
tooltipPositionMode = null;
|
||||||
|
if (bulleState.hideTimer) {
|
||||||
|
clearTimeout(bulleState.hideTimer);
|
||||||
|
bulleState.hideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ferme un popup épinglé donné. */
|
||||||
|
function _closePinnedPopup(el) {
|
||||||
|
const idx = pinnedPopups.findIndex(p => p.el === el);
|
||||||
|
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ferme tous les popups épinglés. */
|
||||||
|
function closeAllPinnedPopups() {
|
||||||
|
for (const p of pinnedPopups.slice()) {
|
||||||
|
p.el.remove();
|
||||||
|
}
|
||||||
|
pinnedPopups.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
|
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
|
||||||
@@ -5008,22 +5319,29 @@ function bindTooltipInteractions() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-Ctrl : pin/unpin
|
// Double-Ctrl : v4.3.0
|
||||||
|
// - Si 0 popup épinglé ET un tooltip live visible : épingler
|
||||||
|
// - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer
|
||||||
|
// - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap)
|
||||||
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
|
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
|
||||||
let lastCtrlTs = 0;
|
let lastCtrlTs = 0;
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Control") return;
|
if (e.key !== "Control") return;
|
||||||
// Ignorer si la touche est répétée (hold)
|
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - lastCtrlTs < 400) {
|
if (now - lastCtrlTs < 400) {
|
||||||
// Double-Ctrl détecté
|
|
||||||
lastCtrlTs = 0;
|
lastCtrlTs = 0;
|
||||||
if (bulleState.pinned) {
|
if (pinnedPopups.length === 0) {
|
||||||
unpinTooltip();
|
// Aucun popup épinglé : épingler le tooltip live s'il y en a un
|
||||||
} else if (state.currentTooltipIv) {
|
if (state.currentTooltipIv) pinTooltip();
|
||||||
pinTooltip();
|
} else if (pinnedPopups.length === 1) {
|
||||||
|
// 1 popup épinglé : le fermer si la souris n'est pas dessus
|
||||||
|
const p = pinnedPopups[0];
|
||||||
|
if (!p.el.matches(":hover")) {
|
||||||
|
_closePinnedPopup(p.el);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 2+ popups : rien faire (Échap pour tout fermer)
|
||||||
} else {
|
} else {
|
||||||
lastCtrlTs = now;
|
lastCtrlTs = now;
|
||||||
}
|
}
|
||||||
@@ -5037,9 +5355,9 @@ function bindTooltipInteractions() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
if (action === "pin") {
|
if (action === "pin") {
|
||||||
if (bulleState.pinned) {
|
// v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup
|
||||||
unpinTooltip();
|
// détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap.
|
||||||
} else if (state.currentTooltipIv) {
|
if (state.currentTooltipIv) {
|
||||||
pinTooltip();
|
pinTooltip();
|
||||||
}
|
}
|
||||||
} else if (action === "reload") {
|
} else if (action === "reload") {
|
||||||
|
|||||||
Reference in New Issue
Block a user