Files
Forge-Engine/static/js/play/offline/render-element.js
T
williamandClaude Sonnet 5 f3b72d73c9
Build and deploy / test-python (push) Successful in 9m20s
Build and deploy / test-js (push) Successful in 1m6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Export Web/SCORM : port complet du runtime jouable côté navigateur
Phase 1.2 de la feuille de route produit — un paquet SCORM tourne seul
dans le LMS du client, sans serveur Forge Engine disponible. Ajoute
static/js/play/offline/ (miroir JS de screens/rendering/ et
data_actions/, sous window.FORGE_OFFLINE) pour que variables, score,
données d'objet, répéteurs et conditions de visibilité fonctionnent
entièrement en mémoire côté navigateur ; branche actions.js et
bindings.js dessus au lieu d'un fetch() serveur. Ajoute
publish/build_scorm_package.py (paquet statique + imsmanifest.xml
SCORM 1.2 + wrapper API SCORM) et le bouton "Exporter (Web/SCORM)".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 07:43:02 +02:00

327 lines
16 KiB
JavaScript

// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port de screens/rendering/render_element_html.py et de
// ses petites briques (attr_string.py, apply_ctx.py, style_string.py,
// visible_attrs.py, parent_flex_direction.py, auto_id.py), regroupées ici
// (un seul fichier JS plutôt qu'un par brique Python : elles sont trop
// petites individuellement pour justifier un fichier chacune côté client).
//
// Widgets à balise simple (texte, titre, bouton, lien, image, vidéo,
// champs de saisie, listes, séparateur, conteneur) rendus directement
// dans ce fichier ; les 10 widgets à "special_render" (case à cocher/
// radio, liste déroulante, groupe de champs, tableau, répéteur, jauge,
// superposition, onglets, icône, personnage) vivent chacun dans leur
// propre fichier (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js, chargés avant celui-ci dans templates/
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
// via forgeSpecialRenderer().
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
// l'éditeur, qui ne concerne que le port serveur).
var FORGE_SIMPLE_WIDGETS = {
texte: { tag: "p", hasContent: true },
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
lien: { tag: "a", hasContent: true },
image: { tag: "img", hasContent: false, selfClosing: true },
video: { tag: "video", hasContent: false },
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
liste: { tag: "ul", hasContent: true },
liste_numerotee: { tag: "ol", hasContent: true },
separateur: { tag: "hr", hasContent: false, selfClosing: true },
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
};
// widget -> clé de special_render (voir screens/widgets/registry.py).
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
};
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
function forgeHtmlEscape(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
}
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
function forgeApplyCtx(text, ctx) {
if (!ctx || !text) return text;
var result = text;
Object.keys(ctx).forEach(function (k) {
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
result = result.split("{{" + k + "}}").join(ctx[k]);
});
return result;
}
function forgeAutoId(el) {
return "elt-" + el.id;
}
// Port de parent_flex_direction.py.
function forgeParentFlexDirection(parentEl) {
var style = (parentEl && parentEl.style) || {};
if (style.display !== "flex") return null;
return style["flex-direction"] || "row";
}
// Port de style_string.py — même logique de taille par défaut (100% hors
// répéteur/conteneur, adaptée au contenu + à l'axe flex du parent pour un
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
function forgeStyleString(el, parentFlexDirection, widgetKey) {
var style = Object.assign({}, el.style || {});
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
style.display = "flex";
if (!("flex-direction" in style)) style["flex-direction"] = "column";
}
var isChild = !!el.parent_id;
var baseStyle;
if (isChild) {
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
} else {
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
}
var hasSizeOverride = false;
var parts = [];
Object.keys(style).forEach(function (k) {
var v = style[k];
if (v === null || v === undefined || v === "") return;
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
if (k === "transform" && !isChild) return;
var strV = String(v).trim();
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
if (k === "width" || k === "height") hasSizeOverride = true;
parts.push(k + ":" + v);
});
if (hasSizeOverride) parts.push("flex-shrink:0");
var userStyle = parts.join("; ");
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
}
// Port de attr_string.py.
function forgeAttrString(attrs, extraStyle) {
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === "" || v === false) return;
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
});
return parts.join(" ");
}
// Port de visible_attrs.py.
function forgeVisibleAttrs(el, meta, ctx) {
var rawAttrs = el.attributes || {};
var attrs = {};
Object.keys(rawAttrs).forEach(function (k) {
if (k.indexOf("_") === 0) return;
var v = rawAttrs[k];
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
});
Object.assign(attrs, meta.fixedAttrs || {});
var extraClasses = Object.keys(rawAttrs)
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
.map(function (k) { return rawAttrs[k]; });
if (extraClasses.length) {
var baseClass = attrs.class || "";
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
}
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
attrs["data-element-id"] = el.id;
if (el.parent_id) attrs["data-anim-target"] = el.id;
return attrs;
}
// Port de render_children.py.
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
var kids = (childrenMap && childrenMap[el.id]) || [];
var flexDir = forgeParentFlexDirection(el);
return kids.map(function (c) {
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
}).join("");
}
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
function forgeResolveBoundRowCtx(gameData, attrs) {
var definitionId = attrs._data_definition_id;
if (!definitionId) return null;
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return null;
var rows = (gameData.data && gameData.data[definitionId]) || [];
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
if (!rows.length) return null;
var row = rows[0];
var ctx = {};
fieldsMetaForDef.forEach(function (f) {
var v = row[forgeFieldColumn(fieldsMetaForDef, f.name)];
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
});
return ctx;
}
// Port de render_element_html.py:_render_element_type_children — contenu
// d'un exemplaire d'élément de jeu réutilisable posé sur un écran, rejoué
// à chaque affichage depuis gameData.element_type_templates (précalculé
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
// d'éventuels enfants copiés en base.
function forgeRenderElementTypeChildren(el, gameData, ctx) {
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
if (!templateTree || !templateTree.top.length) return [null, false];
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
var top = templateTree.top;
if (isOverlayOnly) {
var overlayRoot = Object.assign({}, top[0]);
var newStyle = {};
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
overlayRoot.style = newStyle;
top = top.slice();
top[0] = overlayRoot;
}
var html = top.map(function (c) {
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
}).join("");
return [html, isOverlayOnly];
}
// Les 10 widgets à special_render (voir render-jauge.js, render-overlay.js,
// render-repeater.js, render-special-widgets.js — chargés avant ce
// dispatcher dans templates/play.html) sont tous portés (étape 4 du plan
// terminée) ; ce registre reste indirection unique nécessaire pour éviter
// une dépendance circulaire fichier-à-fichier (chacun de ces fichiers a
// besoin, en retour, de forgeRenderElementHtml ci-dessous pour ses propres
// enfants/onglets/superposition).
function forgeSpecialRenderer(specialKey) {
switch (specialKey) {
case "checkbox_label": return forgeRenderCheckboxOrRadio;
case "select_options": return forgeRenderSelect;
case "fieldset_legend": return forgeRenderFieldset;
case "table": return forgeRenderTable;
case "repeater": return forgeRenderRepeater;
case "jauge": return forgeRenderJauge;
case "overlay": return forgeRenderOverlay;
case "onglets": return forgeRenderOnglets;
case "icone": return forgeRenderIcone;
case "personnage": return forgeRenderPersonnage;
default: return null;
}
}
// Port de render_element_html.py — dispatcher principal. `slug` n'existe
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
var widgetKey = el.widget;
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
var attrsRaw = el.attributes || {};
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
var playMode = !!((ctx || {})._forge_play_mode);
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
return '<div class="visibilityGated" data-element-id="' + el.id + '" style="display:none;"></div>';
}
var hasDataBinding = !!attrsRaw._data_definition_id;
var mark = function (htmlStr) {
var markers = "";
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
if (hasDataBinding) markers += "<!--dataBound-->";
return htmlStr + markers;
};
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
// meta d'un widget à special_render : fixedAttrs propre (voir
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
if (specialKey) {
var renderer = forgeSpecialRenderer(specialKey);
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
}
var tag = meta.tag;
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
var niveau = attrsRaw._niveau || "2";
tag = "h" + niveau;
}
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
var hoverText = attrsRaw._survol_texte;
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
if (meta.selfClosing) {
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
}
var content = forgeApplyCtx(el.content || "", ctx);
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
if (childHtml === null) {
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
} else if (isOverlayTemplate) {
delete attrs.class;
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
}
if (tag === "ul" || tag === "ol") {
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
}
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
}
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
// les 10 widgets à special_render, des fonctions définies dans D'AUTRES
// fichiers (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js) — de vrais globaux dans le navigateur (tous
// chargés ensemble par templates/play.html), mais qui n'existent PAS
// automatiquement ici : un test qui exerce le dispatcher sur un widget à
// special_render doit passer par __tests__/_load_offline_runtime.js
// (charge tout, dans l'ordre, comme le fait play.html) plutôt que
// require('../render-element.js') seul — sans quoi requérir ces fichiers
// D'ICI créerait un cycle (ils ont eux-mêmes besoin, en retour, des
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
};
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
var filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn;
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
global.forgeFilterRowsByClauses = global.forgeFilterRowsByClauses || filterMod.forgeFilterRowsByClauses;
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}