Version 4.2.3 — Grande popup timeline persistante (bindTimelinePopover)

This commit is contained in:
2026-04-19 12:00:00 +02:00
parent 0b08ca122b
commit 7f78493859
5 changed files with 387 additions and 44 deletions
+281 -25
View File
@@ -251,22 +251,91 @@ async function fetchAndShowCurrentUser() {
try {
const resp = await sendMessage({ type: "fetchCurrentUser" });
if (!resp || !resp.ok || !resp.user) return;
const el = document.getElementById("current-user");
if (!el) return;
const label = resp.user.name || resp.user.login || null;
if (!label) return;
el.textContent = label;
el.title = resp.user.login
? `Utilisateur EasyVista connecté : ${label} (${resp.user.login})`
: `Utilisateur EasyVista connecté : ${label}`;
el.classList.remove("hidden");
// Exposer au reste du code pour un usage éventuel plus tard
const badge = document.getElementById("user-badge");
if (!badge) return;
const fullName = resp.user.name || resp.user.login || null;
if (!fullName) return;
const initials = computeUserInitials(fullName);
badge.textContent = initials;
badge.title = fullName;
// v4.2.3 : couleur unique dérivée du nom, dans la palette neutre du thème
badge.style.setProperty("--user-badge-color", colorFromName(fullName));
badge.classList.remove("hidden");
state.currentUser = resp.user;
} catch (err) {
console.warn("[currentUser] fetch failed:", err);
}
}
// 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.
function computeUserInitials(fullName) {
if (!fullName) return "?";
// Format "Nom, Prénom" → prendre initiale avant virgule et après
let parts;
if (fullName.includes(",")) {
parts = fullName.split(",").map(s => s.trim()).filter(Boolean);
} else {
parts = fullName.split(/\s+/).filter(Boolean);
}
const letters = parts
.map(p => p.charAt(0))
.filter(c => /[A-Za--ÿ]/.test(c))
.slice(0, 2)
.join("")
.toUpperCase();
return letters || (fullName.charAt(0).toUpperCase() || "?");
}
// v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre
// (tons tamisés), compatible avec les thèmes clair et sombre de l'extension.
function colorFromName(name) {
// Hash simple (djb2) pour dériver un index stable
let h = 5381;
for (let i = 0; i < name.length; i++) {
h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff;
}
const palette = [
"#5b6372", // gris bleuté
"#6b7280", // gris neutre
"#4a5568", // ardoise
"#3b5a72", // bleu profond tamisé
"#4f6a5e", // vert sauge sombre
"#6b5a4f", // brun taupe
"#5d4a6b", // prune sombre
"#6a5a3a", // kaki bronze
"#3a5a5e", // sarcelle sombre
"#6c5c67" // mauve grisé
];
return palette[Math.abs(h) % palette.length];
}
// v4.2.3 : affiche/masque la popup nom complet sous la pastille
function toggleUserNamePopup() {
const badge = document.getElementById("user-badge");
const popup = document.getElementById("user-name-popup");
if (!badge || !popup) return;
if (!popup.classList.contains("hidden")) {
hideUserNamePopup();
return;
}
if (!state.currentUser || !state.currentUser.name) return;
popup.textContent = state.currentUser.name;
popup.classList.remove("hidden");
badge.classList.add("open");
// Positionne juste en dessous de la pastille
const r = badge.getBoundingClientRect();
popup.style.left = Math.max(8, r.left) + "px";
popup.style.top = (r.bottom + 6) + "px";
}
function hideUserNamePopup() {
const popup = document.getElementById("user-name-popup");
const badge = document.getElementById("user-badge");
if (popup) popup.classList.add("hidden");
if (badge) badge.classList.remove("open");
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
@@ -337,6 +406,26 @@ function bindTopbar() {
if (e.target.value) loadForDate(e.target.value);
});
// v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet
const userBadge = document.getElementById("user-badge");
if (userBadge) {
userBadge.addEventListener("click", (e) => {
e.stopPropagation();
toggleUserNamePopup();
});
}
// Clic ailleurs ou touche Escape ferme la popup
document.addEventListener("click", (e) => {
const popup = document.getElementById("user-name-popup");
if (!popup || popup.classList.contains("hidden")) return;
// Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
if (e.target.closest("#user-name-popup") || e.target.closest("#user-badge")) return;
hideUserNamePopup();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") hideUserNamePopup();
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// v4.2 : écran "EasyVista inaccessible"
@@ -2322,8 +2411,152 @@ function getStatusClass(iv) {
function bindTimelinePopover(el) {
el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el));
el.addEventListener("mousemove", moveTooltip);
// v4.2.3 : la petite popup timeline SUIT la souris (différent de la grande
// popup des lignes d'intervention qui est ancrée). On n'utilise pas
// moveTooltip() (no-op depuis v4.1.12) mais une fonction dédiée.
el.addEventListener("mousemove", (e) => moveTimelineTooltip(e));
el.addEventListener("mouseleave", hideTooltip);
// v4.2.3 : clic / double-clic / Ctrl+clic sur un segment timeline
// - clic simple : ferme la petite popup et ouvre la GRANDE popup
// (ancrée juste en dessous de la timeline, persistante pour permettre
// de sélectionner du texte / copier)
// - double-clic : ouvre la fiche EasyVista dans un nouvel onglet actif
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
const kind = el.dataset.kind;
const ivIdxStr = el.dataset.ivIdx;
// Seulement sur les segments avec une intervention (pas les "hole" libres
// ni certaines absences sans ivIdx)
if (ivIdxStr === undefined) return;
let singleClickTimer = null;
el.addEventListener("click", (e) => {
// Ctrl / Cmd / molette → ouvrir fiche en arrière-plan
if (e.ctrlKey || e.metaKey || e.button === 1) {
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: true });
return;
}
// Clic simple (sans Ctrl) : on attend un éventuel double-clic avant
// d'ouvrir la grande popup persistante.
e.stopPropagation();
if (singleClickTimer) clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
openPersistentTimelinePopup(el);
}, 250);
});
el.addEventListener("dblclick", (e) => {
// Annuler le clic simple en attente
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; }
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: false });
});
}
// v4.2.3 : positionne la petite popup timeline à côté du curseur
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),
// 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;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY;
if (x < 4) x = 4;
if (y < 4) y = 4;
tip.style.left = x + "px";
tip.style.top = y + "px";
currentTooltipPos = { x, y };
}
// v4.2.3 : trouve l'iv correspondant au segment timeline et ouvre sa fiche
function openInterventionFromTimeline(el, opts) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
if (!actionId) return;
// Récupère l'iv depuis state
const iv = findIvByActionId(actionId);
if (!iv) return;
openInterventionInNewTab(iv, opts || {});
}
function findIvByActionId(actionId) {
const data = state.currentData;
if (!data || !data.techs) return null;
for (const tech of data.techs) {
for (const iv of (tech.interventions || [])) {
if (String(iv.actionId) === String(actionId)) return iv;
}
}
return null;
}
// v4.2.3 : ouvre la GRANDE popup (comme au hover sur une ligne) mais ancrée
// juste en dessous du segment timeline cliqué, et épinglée pour qu'elle
// reste ouverte et autorise la sélection de texte.
function openPersistentTimelinePopup(el) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
const iv = findIvByActionId(actionId);
if (!iv) return;
// Fermer toute popup en cours (petite timeline ou grande bulle)
const tip = tooltipEl();
if (!tip) return;
// Reset de l'état pour qu'un hideTooltip forcé ne soit pas bloqué
bulleState.pinned = false;
hideTooltip({ force: true });
// Reset position mémorisée pour éviter re-application au scroll sur
// l'ancienne position
currentTooltipPos = null;
// Construit et affiche la grande bulle, ancrée sous la timeline
tip.innerHTML = buildTooltipHTML(iv);
tip.classList.remove("hidden");
tip.classList.add("visible");
tip.dataset.mode = "anchored";
state.currentTooltipIv = iv;
// Position : juste en dessous de l'élément timeline, aligné à gauche
const r = el.getBoundingClientRect();
const tipRect = tip.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) {
// Pas assez de place en bas : on met au-dessus de la timeline
y = r.top - tipRect.height - 8;
}
if (y < 4) y = 4;
tip.style.left = x + "px";
tip.style.top = y + "px";
currentTooltipPos = { x, y };
// Épingler pour que la popup reste ouverte (elle ne se fermera que sur
// Échap, clic sur ✕ ou double-Ctrl). Permet aussi la sélection de texte.
bulleState.pinned = true;
tip.classList.add("pinned");
}
function showTimelinePopover(e, el) {
@@ -2354,10 +2587,15 @@ function showTimelinePopover(e, el) {
</dl>`;
}
const tip = tooltipEl();
// v4.2.3 : si une grande bulle est déjà épinglée via clic timeline, on ne
// la remplace pas par la petite popup hover.
if (bulleState.pinned && tip.dataset.mode === "anchored") return;
tip.innerHTML = html;
tip.classList.remove("hidden");
tip.classList.remove("hidden", "pinned");
tip.classList.add("visible");
moveTooltip(e);
// v4.2.3 : mode "hover" = petite popup qui suit la souris
tip.dataset.mode = "hover";
moveTimelineTooltip(e);
}
// ============================================================================
@@ -2785,9 +3023,15 @@ function extractContacts(raw) {
// Virer les labels parasites (Nom utilisateur, etc.) qui traînent
s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, "");
// Séparer sur " ou ", " / ", retours à la ligne
// Mais attention : "Nom, Prénom" contient une virgule qu'on ne doit pas découper
const parts = s.split(/\s+ou\s+|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
// v4.2.3 : séparer sur plus de délimiteurs pour gérer les cas type
// "Nom1 Prénom1 +41XXXXXXXXX et Nom2 Prénom2 0XXXXXXXXX"
// Délimiteurs acceptés :
// - " ou " / " et " / " and " (mots de liaison)
// - " / " suivi d'une majuscule (nouveau contact)
// - " ; " (point-virgule)
// - saut de ligne
// IMPORTANT : on ne touche PAS aux virgules (car "Nom, Prénom" en contient).
const parts = s.split(/\s+ou\s+|\s+et\s+|\s+and\s+|\s*;\s*|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
.map(p => p.trim())
.filter(Boolean);
@@ -2829,10 +3073,16 @@ function splitOneContact(raw) {
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres.
const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : 5 chiffres isolés. v4.1.20 : on retire l'exigence de séparateur
// après pour tolérer "12345Texte". Avant = début, whitespace ou
// parenthèse (on évite de prendre 5 chiffres au milieu d'un long).
const rxShort = /(?:^|[\s(\/])(\d{5})(?!\d)/g;
// SHORT : numéro interne court (5 chiffres).
// - v4.1.20 : accepte "12345Texte" (pas de séparateur après)
// - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro,
// typique du Canton de Vaud : "7 68 43", "6 12 34", "8 90 12".
// Doit commencer par 6, 7 ou 8 (plan de numérotation interne VD).
// Pattern : [678] + (4 autres chiffres, avec ou sans espaces/points
// intercalés, mais pas plus d'un séparateur à la fois entre 2 chiffres).
// - v4.2.3 : la version "collée" classique (5 chiffres sans espace, tout
// chiffre de début) reste acceptée comme fallback.
const rxShort = /(?:^|[\s(\/])([678](?:[\s.\-]?\d){4})(?!\d)|(?:^|[\s(\/])(\d{5})(?!\d)/g;
// Trouver toutes les positions de match pour LONG et SHORT
const matches = [];
@@ -2846,12 +3096,18 @@ function splitOneContact(raw) {
}
}
while ((mm = rxShort.exec(raw)) !== null) {
const shortTel = mm[1];
const shortStart = mm.index + mm[0].indexOf(shortTel);
const shortEnd = shortStart + shortTel.length;
const overlaps = matches.some(x => shortStart < x.end && shortEnd > x.start);
// v4.2.3 : soit le 1er groupe (format avec espaces "7 68 43"), soit le
// 2e groupe (format collé "12345") a matché.
const rawTel = mm[1] || mm[2];
if (!rawTel) continue;
// On normalise en 5 chiffres sans séparateur
const shortTel = rawTel.replace(/[\s.\-]/g, "");
if (!/^\d{5}$/.test(shortTel)) continue;
const rawStart = mm.index + mm[0].indexOf(rawTel);
const rawEnd = rawStart + rawTel.length;
const overlaps = matches.some(x => rawStart < x.end && rawEnd > x.start);
if (!overlaps) {
matches.push({ start: shortStart, end: shortEnd, tel: shortTel });
matches.push({ start: rawStart, end: rawEnd, tel: shortTel });
}
}
matches.sort((a, b) => a.start - b.start);