// ---------- Données liées : résolution des placeholders, rafraîchissement ---------- // Extrait de templates/play.html (voir plan de modularisation). // Résout, sur l'écran couramment affiché, les placeholders "champ entre // doubles accolades" restés tels quels dans le HTML statique (hors // Répéteur, donc jamais résolus au moment du rendu serveur) avec les // valeurs de la DERNIÈRE ligne ouverte via l'action "Ouvrir la ligne // cliquée" (window.currentOpenRow). On part à chaque fois du HTML // D'ORIGINE (el.rendered_html, jamais modifié) et pas du DOM déjà // affiché, sinon rouvrir un mail différent laisserait les valeurs du mail // précédent là où le nouveau mail n'a pas ce champ. // // Note : on construit les accolades doubles caractère par caractère // (PLACEHOLDER_OPEN/CLOSE) plutôt que de les écrire en dur — {{ et }} // adjacents dans templates/play.html seraient interprétés par Jinja (le // moteur de templates serveur) comme DU JINJA ; ici, dans un fichier // statique jamais passé par Jinja, ce n'est plus nécessaire, mais la // construction est conservée pour rester visuellement cohérente avec le // reste du moteur et éviter toute confusion en relisant le code. var PLACEHOLDER_OPEN = '{' + '{'; var PLACEHOLDER_CLOSE = '}' + '}'; function applyOpenRowBindings() { if (!window.currentOpenRow) return; const rows = gameData.data[String(window.currentOpenRow.definitionId)] || []; const row = rows.find(r => r.id === window.currentOpenRow.rowId); const screenData = screensData.find(s => s.id === window.currentScreenId); const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]'); if (!row || !screenData || !screenDiv) return; // Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les // éléments qui portent vraiment un {{champ}} non résolu — et non plus // seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce // correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail // content" posé dans un conteneur, lui-même dans un autre) forçait à // regénérer TOUT son ancêtre de premier niveau — potentiellement // l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait // qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un // rechargement de page (flash à vide) à chaque clic sur une ligne. // // Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que // les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de // ses DESCENDANTS sont tous deux candidats, seul le descendant est // régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses // descendants, donc un conteneur "candidat" ne l'est souvent que parce // qu'un descendant à lui l'est — le garder en plus regénérerait // inutilement tout son sous-arbre, à l'exact opposé du but recherché). // ATTENTION : la version précédente de ce filtre vérifiait l'inverse // (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre // est presque toujours "candidat" dès qu'un descendant l'est (son HTML // le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine // de l'écran, annulant complètement l'effet recherché par ce correctif. const byId = {}; screenData.elements.forEach(function(e){ byId[e.id] = e; }); function hasUnresolvedPlaceholder(e) { return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1); } function isDescendantOf(e, ancestorId) { let p = e.parent_id ? byId[e.parent_id] : null; while (p) { if (p.id === ancestorId) return true; p = p.parent_id ? byId[p.parent_id] : null; } return false; } const candidates = screenData.elements.filter(hasUnresolvedPlaceholder); const targets = candidates.filter(function(e) { 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; if (target.querySelector('[data-bind-field]')) { updateTextSlots(target); } else { resolveTextNodes(target); } resolveAttributes(target); }); } // Recharge l'état du jeu depuis le serveur (après une action qui a // modifié une donnée d'objet) pour refléter la nouvelle valeur — utile // pour un Répéteur de données, qui doit réafficher ses lignes à jour. // Important : on ne touche QU'AUX éléments qui contiennent réellement un // répéteur (repérable par la classe "repeaterItem" dans leur HTML rendu). // Reconstruire TOUT l'écran à chaque rafraîchissement effacerait les // changements purement visuels faits en même temps par une action // "Modifier un élément" (ex: masquer une boîte) déclenchée par le même // clic, puisque ces changements ne sont jamais enregistrés côté serveur. function refreshRuntimeData() { return fetch(FORGE_PLAY_URLS.runtimePayload) .then(r => r.json()) .then(newData => { gameData = newData; screensData = gameData.screens; const screenData = screensData.find(s => s.id === window.currentScreenId); const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]'); // Le bloc ci-dessous (régénération du contenu de l'écran courant) // est sauté si l'écran n'est pas trouvé, mais bindClicks() et les // autres réattachements juste après ne doivent JAMAIS dépendre de // cette condition : un retour anticipé ici a déjà laissé, dans la // pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire // de clic pour le reste de la partie (plus aucune ligne ne // répondait, silencieusement, sans erreur) — ces fonctions sont sans // risque à rappeler même quand rien n'a changé. if (screenData && screenDiv) { // try/catch : une erreur inattendue pendant la régénération (ex. // structure de données imprévue) ne doit jamais empêcher les // réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou // tout autre élément cliquable) reste silencieusement inerte pour // le reste de la partie, sans le moindre message d'erreur visible. try { // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée // à une donnée), "visibilityGated" (condition de visibilité) et // "dataBound" ("Donnée liée" d'un Texte/Titre — voir // render_element_html.py) sont les types d'éléments dont le // contenu/l'affichage dépend de la donnée elle-même plutôt que // d'une action "Modifier un élément" posée à la main dans le // graphe — eux seuls doivent être régénérés après un changement // de donnée ou de variable. // // On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication // (pas seulement le premier niveau de l'écran), via leur // data-element-id — disponible sur chaque élément rendu, pas // seulement les éléments de premier niveau. Avant ce correctif, // un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS // du premier niveau, donc c'est son ANCÊTRE de premier niveau // qui portait le marqueur (le Répéteur imbriqué à l'intérieur) // et se retrouvait régénéré en entier à sa place — potentiellement // l'écran entier (jauges, onglets... compris) s'il n'y avait // qu'un seul gros conteneur racine. // // Parmi les éléments marqués, on ne garde que les plus // SPÉCIFIQUES (les plus profonds) : un ancêtre est presque // toujours "marqué" lui aussi dès qu'un descendant l'est, puisque // son rendered_html contient celui de ses descendants — le // garder EN PLUS régénérerait tout son sous-arbre pour rien. // (Une version antérieure de ce filtre vérifiait l'inverse — // exclure un élément si un ANCÊTRE est marqué — ce qui ne // gardait en pratique que l'ancêtre racine de l'écran et // annulait complètement l'effet recherché.) const byId = {}; screenData.elements.forEach(function(e){ byId[e.id] = e; }); function hasMarker(e) { return !!(e.rendered_html && ( e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1 || e.rendered_html.indexOf('dataBound') !== -1 )); } function isDescendantOf(e, ancestorId) { let p = e.parent_id ? byId[e.parent_id] : null; while (p) { if (p.id === ancestorId) return true; p = p.parent_id ? byId[p.parent_id] : null; } return false; } // "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE // HTML juste après la balise concernée (voir _mark() dans // render_element_html.py) — jamais comme une classe CSS, à la // différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent // un widget à régénérer EN BLOC, lignes/valeur comprises, un // comportement déjà correct pour ces deux-là). Repérer ce // commentaire permet de retrouver, à l'intérieur d'un fragment // fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il // est niché dans un exemplaire d'élément de jeu réutilisable // (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de // dialogue), où lui seul porte l'attribut data-element-id qui // l'identifie, son marqueur ne remontant jusqu'au SEUL élément // de premier niveau existant (l'exemplaire) que parce que son // HTML est imbriqué dans le sien. const COMMENT_MARKERS = ['visibilityGated', 'dataBound']; function findCommentMarkedDescendants(root) { const found = []; root.querySelectorAll('[data-element-id]').forEach(function(node) { const sib = node.nextSibling; const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1); // Un élément actuellement CACHÉ par une condition de // visibilité (voir render_element_html.py) n'a pas de // commentaire marqueur à sa suite — sa balise porte // directement la classe "visibilityGated" à la place. Sans // ce second cas, un élément qui passe de VISIBLE à CACHÉ // n'était jamais retrouvé ici (seul le sens caché->visible // l'était, via le commentaire) : son ancien nœud visible // restait donc affiché indéfiniment à côté du nouveau, // maintenant visible, qui le remplace normalement (ex. une // icône "enveloppe fermée" jamais masquée en même temps que // "enveloppe ouverte" qui apparaît après avoir cliqué). const isHiddenPlaceholder = node.classList.contains('visibilityGated'); if (hasCommentMarker || isHiddenPlaceholder) { found.push(node); } }); return found; } const markedCandidates = screenData.elements.filter(hasMarker); markedCandidates.filter(function(e) { return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); }); }).forEach(function(el) { const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]'); if (!target) return; const container = document.createElement('div'); container.innerHTML = el.rendered_html; const newNode = container.firstElementChild; if (!newNode) return; const innerMarked = findCommentMarkedDescendants(newNode); if (innerMarked.length) { // Des éléments plus précis existent À L'INTÉRIEUR (ex. un // Texte "Donnée liée" posé dans un élément de jeu // réutilisable) : on ne patche QUE ceux-là, en préservant // tout le reste — une boîte de dialogue ne doit ni // disparaître ni réapparaître juste parce qu'un texte à // l'intérieur a changé. innerMarked.forEach(function(innerNode) { const innerId = innerNode.getAttribute('data-element-id'); const liveInner = target.querySelector('[data-element-id="' + innerId + '"]'); if (liveInner) liveInner.replaceWith(innerNode); }); } else { target.replaceWith(newNode); } }); applyOpenRowBindings(); } catch (err) { console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err); } } bindClicks(); bindHoverTexts(); bindHoverTriggers(); // Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où // l'on se trouve déjà : une donnée vient de changer (ex: un outil // qu'on vient de débloquer), donc l'affichage doit se mettre à jour // tout de suite, sans attendre un aller-retour d'écran. runScreenShowTriggers(window.currentScreenId); }); }