// Export Web/SCORM (voir le plan "port complet du runtime jouable côté // navigateur") — étape 4 : port des widgets à "special_render" les plus // simples (screens/rendering/render_checkbox_or_radio.py, render_select.py, // render_fieldset.py, render_table.py, render_icone.py, render_onglets.py). // Jauge/Superposition/Personnage/Répéteur (logique/données plus lourde) // vivent dans des fichiers dédiés (render-jauge.js, render-repeater.js). // Dépend de render-element.js (forgeVisibleAttrs/forgeStyleString/ // forgeAttrString/forgeApplyCtx/forgeHtmlEscape/forgeAutoId/ // forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier. function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio'; const attrs = forgeVisibleAttrs(el, meta, ctx); attrs.type = inputType; const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`; const content = forgeApplyCtx(el.content || '', ctx); return ``; } function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const outerAttrs = forgeVisibleAttrs(el, meta, ctx); const bulmaClasses = outerAttrs.class || ''; const selectAttrs = {}; const wrapperAttrs = {}; Object.keys(outerAttrs).forEach((k) => { if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k]; else if (k !== 'class') selectAttrs[k] = outerAttrs[k]; }); const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`; const content = forgeApplyCtx(el.content || '', ctx); const options = content.split('\n').map((o) => o.trim()).filter(Boolean); const optsHtml = options.map((o) => { const esc = forgeHtmlEscape(o); return ``; }).join(''); const selectHtml = ``; const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim()); return `
${selectHtml}
`; } function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const attrs = forgeVisibleAttrs(el, meta, ctx); const style = forgeStyleString(el, parentFlexDirection, el.widget); const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs'; const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx); return `
${forgeHtmlEscape(content)}${childHtml}
`; } function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const attrs = forgeVisibleAttrs(el, meta, ctx); const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1; const content = forgeApplyCtx(el.content || '', ctx); const style = forgeStyleString(el, parentFlexDirection, el.widget); const rows = content.split('\n').filter((r) => r.trim()); const htmlRows = rows.map((r, i) => { const cells = r.split(';'); const cellTag = (headerRow && i === 0) ? 'th' : 'td'; const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}`).join(''); return `${cellsHtml}`; }).join(''); return `${htmlRows}
`; } function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { // Contrairement au serveur, ne revalide pas _icon_slug contre le // catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur // invalide ne peut être enregistrée que depuis l'éditeur, qui la valide // déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais. const rawAttrs = el.attributes || {}; const iconSlug = rawAttrs._icon_slug || 'star'; const attrs = forgeVisibleAttrs(el, meta, ctx); const existingClass = attrs.class || ''; delete attrs.class; attrs.class = (`${existingClass} icon-svg`).trim(); // data: URI pré-calculée à l'export (voir gameData.icon_data_uris, // publish/build_scorm_package.py::_build_icon_data_uris) — jamais un // chemin de fichier, même relatif : Firefox traite chaque ressource // file:// comme une origine opaque distincte, et mask-image y déclenche // un fetch en mode CORS quel que soit le chemin ("Blocage d'une requête // multiorigine... la requête CORS n'utilise pas HTTP", signalé par un // utilisateur MALGRÉ un chemin relatif par ailleurs correct). Repli sur // un chemin relatif si jamais l'icône n'a pas été pré-encodée (ne // devrait pas arriver : _collect_icon_slugs scanne tous les widgets // "icone", posés directement ou nichés dans un élément de jeu // réutilisable) — au moins un lien exploitable plutôt qu'un vide. const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`); const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`; // aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de // description dédié dans l'éditeur) est traitée comme purement // décorative — jamais annoncée par un lecteur d'écran, comme le nom du // slug FontAwesome ("star") n'aurait aucun sens lu à voix haute. if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true'; return ``; } function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const tabs = (childrenMap && childrenMap[el.id]) || []; const attrs = forgeVisibleAttrs(el, meta, ctx); const tabsExtraClasses = attrs.class || ''; delete attrs.class; const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`; const groupId = forgeAutoId(el); if (!tabs.length) { return `
Onglets — ajoute au moins un onglet dans ses réglages.
`; } const flexDir = forgeParentFlexDirection(el); const barItems = []; const panels = []; tabs.forEach((tab, i) => { const label = tab.name || (`Onglet ${i + 1}`); const active = i === 0; const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir); const targetId = `tabpanel-${tab.id}`; barItems.push( `
  • ${forgeHtmlEscape(String(label))}
  • `, ); const hiddenStyle = active ? '' : ' style="display:none;"'; panels.push(`
    ${tabHtml}
    `); }); const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim()); return ( `
    ` + `
    ` + `
    ${panels.join('')}
    ` + '
    ' ); } // Port de render_personnage.py — lit directement gameData.personnage_animations // (déjà résolu une fois par full_game_payload.py, à l'export) au lieu de // re-résoudre la bibliothèque de sprites Forge/l'URL personnalisée : ce // travail est fait UNE fois, à la génération du paquet, jamais à chaque // rendu côté client. function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) { const attrs = forgeVisibleAttrs(el, meta, ctx); attrs['data-personnage'] = '1'; const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)]; const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null; const idleFrame = frames && frames.length ? frames[0] : ''; if (idleFrame) attrs.src = idleFrame; // alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut // décoratif sur le widget "image" générique. if (!('alt' in attrs)) attrs.alt = ''; const style = forgeStyleString(el, parentFlexDirection, el.widget); return ``; } if (typeof module !== 'undefined' && module.exports) { module.exports = { forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable, forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage, }; const renderMod = require('./render-element.js'); ['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape', 'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => { global[name] = global[name] || renderMod[name]; }); }