Version 4.2.3 — Grande popup timeline persistante (bindTimelinePopover)
This commit is contained in:
@@ -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-zÀ-ÿ]/.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);
|
||||
|
||||
Reference in New Issue
Block a user