// ---------- 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. const PLACEHOLDER_OPEN = '{' + '{'; const PLACEHOLDER_CLOSE = '}' + '}'; // Partagée entre applyOpenRowBindings() et _forgeApplyFreshRuntimeData() // (même algorithme, dupliqué à l'identique dans les deux avant ce // refactor S4144) : `byId` explicite en paramètre plutôt que capturé par // fermeture, puisque chaque appelant construit sa PROPRE table (éléments // de l'écran au moment de son propre appel, jamais partagés entre les // deux). function isDescendantOfElement(byId, 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; } 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((e) => { byId[e.id] = e; }); function hasUnresolvedPlaceholder(e) { return !!e.rendered_html?.includes(PLACEHOLDER_OPEN); } const candidates = screenData.elements.filter(hasUnresolvedPlaceholder); const targets = candidates.filter( (e) => !candidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, 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.includes(PLACEHOLDER_OPEN)) toSplit.push(node); } toSplit.forEach((textNode) => { const raw = textNode.nodeValue; placeholderRe.lastIndex = 0; const frag = document.createDocumentFragment(); let lastIndex = 0; let 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((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((node) => { Array.from(node.attributes).forEach((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}`; let template = null; if (node.hasAttribute(bindKey)) { template = node.getAttribute(bindKey); } else if (attr.value.includes(PLACEHOLDER_OPEN)) { template = attr.value; } if (template === null) return; if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template); placeholderRe.lastIndex = 0; const resolved = template.replace(placeholderRe, (_, fieldName) => (row[fieldName] == null ? '' : String(row[fieldName]))); if (attr.value !== resolved) node.setAttribute(attr.name, resolved); }); }); } targets.forEach((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() { // Export Web/SCORM (voir le plan "port complet du runtime jouable côté // navigateur") : window.FORGE_OFFLINE (posé UNIQUEMENT dans le paquet // exporté, jamais dans /game//play ni /jouer/) recalcule le // payload EN MÉMOIRE (static/js/play/offline/full-payload.js) au lieu // d'un fetch — le mécanisme de repérage/remplacement DOM qui suit // (_forgeApplyFreshRuntimeData) reste ensuite EXACTEMENT le même code, // en ligne comme hors ligne. if (window.FORGE_OFFLINE) { forgeRecomputeFullPayloadOffline(gameData); return Promise.resolve().then(() => _forgeApplyFreshRuntimeData(gameData)); } return fetch(FORGE_PLAY_URLS.runtimePayload) .then((r) => r.json()) .then(_forgeApplyFreshRuntimeData); } function _forgeApplyFreshRuntimeData(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((e) => { byId[e.id] = e; }); const hasMarker = (e) => !!(e.rendered_html && ( e.rendered_html.includes('repeaterItem') || e.rendered_html.includes('jaugeBar') || e.rendered_html.includes('visibilityGated') || e.rendered_html.includes('dataBound') )); // "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 = new Set(['visibilityGated', 'dataBound']); const findCommentMarkedDescendants = (root) => { const found = []; root.querySelectorAll('[data-element-id]').forEach((node) => { const sib = node.nextSibling; const hasCommentMarker = !!( sib?.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.has(sib.data) ); // 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((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id))) .forEach((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((innerNode) => { const innerId = innerNode.dataset.elementId; 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); }