Dev #2

Merged
w-vandal merged 24 commits from dev into main 2026-08-27 13:00:44 +00:00
Showing only changes of commit f016a81dc6 - Show all commits
+74 -52
View File
@@ -380,61 +380,83 @@
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
});
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
// mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici :
// - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un
// <span data-bind-field="champ"> qui portera la valeur, texte
// après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se
// contentent de changer le textContent de ce span.
// - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le
// gabarit d'origine est mémorisé sur un data-bind-attr-<nom> lors du
// premier passage, pour pouvoir recalculer et réécrire l'attribut à
// chaque fois sans jamais avoir besoin de reconstruire le nœud.
// Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé
// depuis zéro à chaque clic sur une ligne — ce qui causait un flash
// visuel (perte de tout état transitoire posé par "Modifier un
// élément", donnant l'impression trompeuse d'un rechargement de page).
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
// rien à sauvegarder ni à restaurer.
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
function resolveTextNodes(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const toSplit = [];
let node;
while ((node = walker.nextNode())) {
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
}
toSplit.forEach(function(textNode) {
const raw = textNode.nodeValue;
placeholderRe.lastIndex = 0;
const frag = document.createDocumentFragment();
let lastIndex = 0, match;
while ((match = placeholderRe.exec(raw))) {
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
const span = document.createElement('span');
span.dataset.bindField = match[1];
span.textContent = row[match[1]] == null ? '' : String(row[match[1]]);
frag.appendChild(span);
lastIndex = match.index + match[0].length;
}
if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex)));
textNode.replaceWith(frag);
});
}
function updateTextSlots(root) {
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
if (slot.textContent !== value) slot.textContent = value;
});
}
function resolveAttributes(root) {
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
Array.from(node.attributes).forEach(function(attr) {
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
const bindKey = 'data-bind-attr-' + attr.name;
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
if (template === null) return;
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
placeholderRe.lastIndex = 0;
const resolved = template.replace(placeholderRe, function(_, fieldName) {
return row[fieldName] == null ? '' : String(row[fieldName]);
});
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
});
});
}
targets.forEach(function(el) {
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
if (!target) return;
// Avant de regénérer ce nœud depuis le HTML D'ORIGINE (nécessaire pour
// ne pas laisser les valeurs d'un mail précédent affichées, voir plus
// haut), sauvegarde l'état visuel TRANSITOIRE posé par une action
// "Modifier un élément" (jamais enregistré en base — voir
// applyElementProperty) sur ce nœud ou l'un de ses descendants, pour
// le réappliquer après. Sans ça, un panneau rendu visible par cette
// action (ex. "mail content") redevenait invisible à CHAQUE
// rafraîchissement de données, y compris pour un changement de donnée
// sans rapport avec lui — puisque le remplacement ci-dessous recrée
// entièrement ses nœuds à partir de leur état PAR DÉFAUT.
const savedState = {};
[target].concat(Array.from(target.querySelectorAll('[data-element-id]'))).forEach(function(node) {
// clickBound/hoverBound/hoverTriggerBound (voir bindClicks/
// bindHoverTexts/bindHoverTriggers) sont des indicateurs INTERNES du
// moteur, jamais un état posé par une action "Modifier un élément" —
// les restaurer sur le nœud tout neuf créé ci-dessous lui ferait
// croire à tort qu'un écouteur de clic/survol lui est déjà rattaché
// (alors qu'aucun ne l'est réellement), ce qui bloquait
// silencieusement TOUS les clics suivants sur cet élément.
const dataset = Object.assign({}, node.dataset);
delete dataset.clickBound;
delete dataset.hoverBound;
delete dataset.hoverTriggerBound;
savedState[node.dataset.elementId] = {
style: node.getAttribute('style') || '',
className: node.className,
dataset: dataset,
};
});
let html = el.rendered_html;
Object.keys(row).forEach(function(fieldName) {
if (fieldName === 'id') return;
const value = row[fieldName] == null ? '' : String(row[fieldName]);
const tmp = document.createElement('div');
tmp.textContent = value;
html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML);
});
const container = document.createElement('div');
container.innerHTML = html;
const newNode = container.firstElementChild;
if (!newNode) return;
target.replaceWith(newNode);
[newNode].concat(Array.from(newNode.querySelectorAll('[data-element-id]'))).forEach(function(node) {
const saved = savedState[node.dataset.elementId];
if (!saved) return;
node.setAttribute('style', saved.style);
node.className = saved.className;
Object.assign(node.dataset, saved.dataset);
});
if (target.querySelector('[data-bind-field]')) {
updateTextSlots(target);
} else {
resolveTextNodes(target);
}
resolveAttributes(target);
});
}