Files
Forge-Engine/static/js/play/offline/render-special-widgets.js
T
williamandClaude Sonnet 5 ccf836f2c5
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier)
sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*,
.replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(),
getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y
(verifie site par site), extraction de template litteraux imbriques,
ternaires imbriquees, refactors de complexite cognitive (S3776) via
tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions
identiques (S4144). Deux exceptions S2486 documentees/corrigees
(filter-repeater-rows.js) et un cas S2703 de partage inter-scripts
complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js).
Details complets dans CODE_QUALITY.md section 5.

Restaure aussi le job CI "sonarqube" (non-bloquant) dans
.gitea/workflows/deploy.yml maintenant que l'instance prod est
operationnelle.

Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest).

SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021
(styles inline, 49 occurrences/6 templates) deja documente comme dette
assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec
ce commit (aucun template touche ici) - valide explicitement avec
l'utilisateur avant de contourner ce hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:38:29 +02:00

168 lines
8.7 KiB
JavaScript

// 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 `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
}
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 `<option value="${esc}">${esc}</option>`;
}).join('');
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
}
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 `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
}
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
const headerRow = ['1', 'true', 'on'].includes(el.attributes?._header_row);
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())}</${cellTag}>`).join('');
return `<tr>${cellsHtml}</tr>`;
}).join('');
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
}
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?.[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 `<i ${forgeAttrString(attrs, style)}></i>`;
}
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const tabs = 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 `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
}
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(
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
);
const hiddenStyle = active ? '' : ' style="display:none;"';
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
});
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
return (
`<div ${forgeAttrString(attrs, style)}>`
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
+ '</div>'
);
}
// 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?.[String(el.id)];
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
const idleFrame = 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 `<img ${forgeAttrString(attrs, style)}>`;
}
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];
});
}