From f016a81dc610c0421851e6d0ad49a100100b26bf Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 06:24:42 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A9sout=20{{champ}}=20en=20place=20au=20lie?= =?UTF-8?q?u=20de=20recr=C3=A9er=20le=20n=C5=93ud=20(z=C3=A9ro=20flash,=20?= =?UTF-8?q?fa=C3=A7on=20React)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'utilisateur a raison de pointer que le vrai souci n'était pas un bug isolé mais l'APPROCHE elle-même : detruire puis reconstruire un nœud du DOM à chaque clic (même bien ciblé, comme depuis les 2 derniers commits) cause toujours un flash visuel, puisque tout état transitoire du sous-arbre (visibilité posée par "Modifier un élément", focus...) est perdu et reconstruit à neuf. C'est ce qui donnait l'impression trompeuse d'un "rechargement" — un comportement JS parfaitement normal quand on manipule le DOM ainsi, mais évitable : c'est exactement le problème que la réconciliation ciblée de React (ne patcher que ce qui a changé, jamais recréer un nœud pour rien) résout côté framework. applyOpenRowBindings() ne remplace donc plus JAMAIS le nœud de l'élément ciblé (ex. "mail content") — il patche directement, en place : - un nœud TEXTE contenant {{champ}} est coupé en 3 (texte avant, un , texte après) LA PREMIÈRE FOIS SEULEMENT ; toute ouverture suivante se contente de changer le textContent de ce span — plus aucune reconstruction ensuite. - un ATTRIBUT contenant {{champ}} (ex. href="{{link_real_url}}") voit son gabarit d'origine mémorisé sur data-bind-attr- au premier passage, pour être recalculé et réécrit directement à chaque fois sans jamais reconstruire le nœud. Plus aucun nœud n'étant détruit, la sauvegarde/restauration de l'état visuel transitoire (ajoutée dans un commit précédent pour compenser cette destruction) devient inutile et est retirée. Co-Authored-By: Claude Sonnet 5 --- templates/play.html | 126 ++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 52 deletions(-) diff --git a/templates/play.html b/templates/play.html index 7e611467..e1db84c6 100644 --- a/templates/play.html +++ b/templates/play.html @@ -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 + // 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- 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); }); }