teach/public/js/widgets/letters.js
Ramon 84fd02d135
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
fix: libelle en dierenvormen herstellen, instellingen omdraaien (v0.4.81-beta)
- Libelle in de vijverplaat kreeg weer vier symmetrische vleugels
- Zeepaardje in het koraalrif herbouwd van een onherkenbare buis naar een
  duidelijk zeepaardje, verplaatst zodat het niet meer over de clownvis valt
- Nieuwe coloringLegPath()-vorm: giraffe en zebra hebben nu getapte,
  licht geknikte poten in plaats van rechte kokerpoten
- Instellingenindeling omgedraaid: de kleurplaat (thema/plaat) is weer
  direct zichtbaar, kleurhulp/reset gaan achter het tandwiel
- Letterblokken en rekenwidgets: niveauknoppen naar het tandwiel, het
  woord/thema blijft direct zichtbaar
- Klok, geldrekenen, plaatswaardetabel, boom&blad/vogels/planten en
  topografie: spel/activiteit (en bij geld/plaatswaarde het bijpassende
  veld) staan weer in de vaste bovenbalk, niveau staat achter het tandwiel
- Plaatswaardetabel toont nu ook in verkennen-stand een vaste bovenbalk
  (spel, getal, dobbelsteen) in plaats van alleen in bouwen-stand
- Bugfix onderweg: niveauwissel in verkennen-stand van de plaatswaardetabel
  sprong per ongeluk naar bouwen-stand - nu blijft de stand behouden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
2026-07-30 21:39:30 +02:00

814 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* widget: Letterblokken (Ik leer lezen) */
function mountLetterGame(root, initState, opts){
const readonly = !!(opts && opts.readonly);
const onProgress = opts && opts.onProgress;
/* wordMistakes: de missers binnen het huidige woord (welke letter hoorde er,
welke koos de leerling) - gaat mee als voortgangdetail zodra het woord af is */
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"", wordMistakes:[] };
if(initState){
S.level = initState.level||1;
S.yearGroup = initState.yearGroup||null;
S.managedLearningLevel = !!initState.managedLearningLevel;
S.card = initState.card ?? "g";
S.count = initState.count||6;
S.snd = initState.snd !== false;
S.amode = initState.amode||"blocks";
S.assignmentWords = Array.isArray(initState.assignmentWords) ? initState.assignmentWords : null;
S.assignmentThemeName = typeof initState.assignmentThemeName==="string" ? initState.assignmentThemeName : "";
}
const play = f=>{ if(S.snd) f(); };
root.innerHTML = `
<div class="lg">
<div class="lg-top">
<div class="lg-cards"></div>
<div class="lg-progress"><span class="lg-count"></span><span class="lg-stars"></span></div>
</div>
<div class="lg-instr"></div>
<div class="lg-card">
<div class="lg-pic"></div>
<div class="lg-slots"></div>
<div class="lg-done"><div class="dg">🌟</div><div class="dt"></div><div class="dp" style="color:var(--muted);font-weight:700;"></div></div>
</div>
<div class="lg-tray blocks-tray"></div>
<div class="lg-tray lg-kbd"></div>
<div class="lg-bottom"><button class="lg-next"></button></div>
<div class="lg-editor">
<div class="lg-ed-head"><b class="ed-title"></b><button class="wclose lg-ed-close">✕</button></div>
<div class="lg-ed-themerow">
<select class="ed-theme-sel"></select>
<button class="tbtn ed-theme-add"></button>
<input type="text" class="ed-theme-name" maxlength="16" style="display:none;">
<button class="ed-theme-mv">🗂</button>
<button class="ed-theme-del">🗑</button>
</div>
<div class="lg-ed-form">
<input type="text" class="ed-word" maxlength="5">
<div class="ed-preview">?</div>
<button class="ed-add"></button>
<div class="ed-msg"></div>
</div>
<div class="ed-tabs">
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button><button data-t="library"></button>
</div>
<div class="ed-pane">
<div class="ed-p-emoji">
<div class="ed-cats"></div>
<div class="ed-grid"></div>
</div>
<div class="ed-p-picto" style="display:none;">
<div class="ed-pictorow">
<input type="text" class="ed-psearch" maxlength="30">
<button class="tbtn ed-pgo"></button>
</div>
<div class="ed-status"></div>
<div class="ed-pgrid"></div>
<div class="ed-credit"></div>
</div>
<div class="ed-p-upload" style="display:none;">
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
<div class="ed-uphint"></div>
</div>
<div class="ed-p-library" style="display:none;">
<button class="tbtn ed-open-library" type="button"></button>
<p class="ed-library-hint"></p>
</div>
</div>
<div class="lg-ed-list"></div>
</div>
</div>`;
const el = s => root.querySelector(s);
const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"),
instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"),
countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards");
/* de kaartjes (het woord/thema) moeten snel te kiezen zijn - dat verandert
per opdracht. Niveau staat meestal vast (en is bij beheerd niveau zelfs
uitgeschakeld), dus dat gaat samen met geluid/invoerwijze/aantal achter
het tandwiel in plaats van in de brede rij te concurreren om ruimte. */
const levels=document.createElement("div");levels.className="lg-levels";
levels.innerHTML=`<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>`;
const numSel=document.createElement("select");numSel.className="lg-num";
const sndBtn=document.createElement("button");sndBtn.type="button";sndBtn.className="lg-snd";
const modeBtn=document.createElement("button");modeBtn.type="button";modeBtn.className="lg-mode";
el(".lg-top").insertAdjacentElement("beforebegin",widgetGear([
gearField(T("levelWidget"),levels),gearField(T("sndWidget"),sndBtn),gearField(T("amode"),modeBtn),gearField(T("numWords"),numSel)
]));
/* card buttons (built dynamically: N cards + star + edit) */
function buildNumSel(){
numSel.innerHTML = "";
[4,6,8,10,"all"].forEach(v=>{
const o = document.createElement("option");
o.value = v;
o.textContent = v==="all" ? T("all") : v;
numSel.appendChild(o);
});
numSel.value = S.count;
numSel.title = T("numWords");
}
numSel.addEventListener("change", ()=>{
S.count = numSel.value==="all" ? "all" : +numSel.value;
newRound(); render();
});
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
updateSndBtn();
});
function updateSndBtn(){
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
modeBtn.title = T("amode");
root.querySelector(".lg").classList.toggle("typemode", S.amode==="type");
}
modeBtn.addEventListener("click", ()=>{
S.amode = S.amode==="blocks" ? "type" : "blocks";
updateSndBtn();
});
/* on-screen letter keyboard for type mode */
const kbdEl = root.querySelector(".lg-kbd");
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l;
b.className = VOWELS.includes(l) ? "v" : "c";
b.addEventListener("click", ()=>typeLetter(l));
kbdEl.appendChild(b);
});
function typeLetter(l){
if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return;
const slot = slotsEl.querySelector(".slot:not(.filled)");
if(!slot) return;
if(slot.dataset.letter === l){
const b = makeBlock(l);
b.classList.add("in-slot");
b.dataset.placedAt = Date.now();
slot.appendChild(b);
slot.classList.add("filled");
play(sndGood);
checkComplete();
}else{
S.wordMistakes.push({expected: slot.dataset.letter, given: l});
slot.classList.add("shake");
setTimeout(()=>slot.classList.remove("shake"), 400);
play(sndBad);
}
}
/* word source: one compact dropdown (general card + themes + anchor grids) */
let srcSel = null;
function buildCardButtons(){
cardsEl.innerHTML = "";
srcSel = document.createElement("select");
srcSel.className = "fl-sel";
if(S.assignmentWords?.length){
srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));
srcSel.disabled=true;
}else{
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
S.card=srcSel.value||"g";
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
}
cardsEl.appendChild(srcSel);
if(!readonly){
const edit = document.createElement("button");
edit.className = "lg-edit"; edit.textContent = "✎";
edit.addEventListener("click", openEditor);
cardsEl.appendChild(edit);
}
}
buildCardButtons();
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
b.disabled = S.managedLearningLevel;
});
nextBtn.addEventListener("click", ()=>{
S.wordIdx++;
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
});
document.addEventListener("langchange", ()=>{
S.lang=LANG; if(!S.assignmentWords)S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
});
document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render();
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
});
function fullList(){
if(S.assignmentWords?.length)return languageWordsForYear(S.assignmentWords,S.yearGroup,S.level);
S.card = normWordSource(S.card);
const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level);
if(selected.length)return selected;
S.card="g";return languageWordsForYear(wordSourceList("g"),S.yearGroup,S.level);
}
function newRound(){
const list = fullList();
const n = S.count==="all" ? list.length : Math.min(+S.count, list.length);
S.order = shuffle(list.map((_,i)=>i)).slice(0,n);
S.wordIdx = 0; S.stars = 0;
}
function currentWord(){ return fullList()[S.order[S.wordIdx]]; }
function makeBlock(letter){
const b = document.createElement("div");
b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons");
b.dataset.letter = letter;
b.innerHTML = `<div class="bl">${letter}</div>`;
const dg = dotGrid(letter); dg.classList.add("blok-dots");
b.appendChild(dg);
attachDrag(b);
return b;
}
function makeSlot(letter, showGhost){
const s = document.createElement("div");
s.className = "slot";
s.dataset.letter = letter;
s.innerHTML = `<div class="ghost">${showGhost ? letter : ""}</div>`;
const dg = dotGrid(letter); dg.classList.add("slot-dots");
s.appendChild(dg);
s.addEventListener("click", ()=>{
const placed = s.querySelector(".blok");
/* ignore the synthetic click that follows a successful drop */
if(placed && !placed.classList.contains("fixed") &&
Date.now() - (+placed.dataset.placedAt || 0) > 400){
placed.classList.remove("in-slot");
trayEl.appendChild(placed);
s.classList.remove("filled");
}
});
return s;
}
function distractors(word, n){
const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c));
return shuffle(pool).slice(0,n);
}
function render(){
if(!S.order.length || S.wordIdx >= S.order.length) newRound();
doneEl.classList.remove("show");
nextBtn.classList.remove("show");
nextBtn.textContent = T("next");
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level);
});
const editBtn = cardsEl.querySelector(".lg-edit");
if(editBtn) editBtn.title = T("manage");
numSel.title = T("numWords");
const allOpt = numSel.querySelector('option[value="all"]');
if(allOpt) allOpt.textContent = T("all");
const [word, pic] = currentWord();
S.wordMistakes = []; /* nieuw woord op het scherm: missers tellen per woord */
const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words";
setPic(picEl, pic);
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
slotsEl.innerHTML = ""; trayEl.innerHTML = "";
const letters = word.split("");
if(literacyMode==="letters"){
const first=letters[0];
instrEl.textContent=LANG==="nl"?"Welke letter hoor je vooraan?":"Which letter do you hear first?";
slotsEl.appendChild(makeSlot(first,false));
shuffle([first].concat(distractors(word,S.level===1?2:3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else if(S.level===1){
instrEl.innerHTML = T("instr1");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else if(S.level===2){
const types = ["begin","end","mid"];
const posType = types[S.wordIdx % 3];
let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2);
instrEl.innerHTML = T("instr2_"+posType);
letters.forEach((l,i)=>{
if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); }
else{
const s = makeSlot(l,true);
s.classList.add("filled");
const b = makeBlock(l); b.classList.add("in-slot","fixed");
b.style.pointerEvents = "none";
s.appendChild(b);
slotsEl.appendChild(s);
}
});
shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else{
instrEl.innerHTML = T("instr3");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
}
function checkComplete(){
const open = slotsEl.querySelectorAll(".slot:not(.filled)").length;
if(open===0){
S.stars++;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
play(sndWin); confetti(el(".lg-card"));
doneEl.querySelector(".dt").textContent = T("welldone");
doneEl.querySelector(".dp").textContent = currentWord()[0];
doneEl.classList.add("show");
nextBtn.classList.add("show");
/* het woord is af (er is geen faalmoment), maar de missers onderweg
vertellen de leerkracht welke letter lastig was */
if(onProgress) onProgress(languageProgressPayload({
word: currentWord()[0], mistakes: S.wordMistakes, solved: true, stars: 1 }));
S.wordMistakes = [];
}
}
function showCardDone(){
doneEl.querySelector(".dt").textContent = T("cardDone");
doneEl.querySelector(".dp").textContent = T("pickCard");
doneEl.classList.add("show");
nextBtn.classList.remove("show");
celebrateAvatar(root);
newRound();
}
/* ---------- custom words editor ---------- */
const edEl = el(".lg-editor"), edWord = el(".ed-word"),
edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"),
edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"),
edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"),
edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"),
edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del"),
edThemeMv = el(".ed-theme-mv");
let edPic = "", edTab = "emoji", edCat = 0, edTheme = "g";
/* ---- word target: the general card or one of the themes ---- */
function renderThemeSel(){
const list = THEMES[S.lang];
if(edTheme !== "g" && edTheme >= list.length) edTheme = "g";
edThemeSel.innerHTML = "";
const og = document.createElement("option");
og.value = "g";
og.textContent = `${T("cardStart")} (${generalList().length})`;
edThemeSel.appendChild(og);
/* thema's zonder map bovenaan, daarna per map gegroepeerd - zelfde
indeling als fillWordSourceSel (data.js) in de widget-dropdowns */
const groups = new Map();
list.forEach((th,i)=>{
const f = th.folder || "";
if(!groups.has(f)) groups.set(f, []);
groups.get(f).push([th, i]);
});
const addOpt = (parent, th, i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${th.name} (${th.words.length})`;
parent.appendChild(o);
};
(groups.get("") || []).forEach(([th,i])=>addOpt(edThemeSel, th, i));
[...groups.keys()].filter(f=>f).sort().forEach(f=>{
const grp = document.createElement("optgroup");
grp.label = "📁 " + f;
groups.get(f).forEach(([th,i])=>addOpt(grp, th, i));
edThemeSel.appendChild(grp);
});
edThemeSel.value = edTheme;
edThemeAdd.title = T("newTheme");
edThemeDel.title = T("delTheme");
edThemeMv.title = T("themeFolder");
edThemeName.placeholder = T("themeNamePh");
}
edThemeSel.addEventListener("change", ()=>{
edTheme = edThemeSel.value==="g" ? "g" : +edThemeSel.value;
renderEdList();
});
edThemeAdd.addEventListener("click", ()=>{
edThemeName.style.display = "block";
edThemeName.value = "";
edThemeName.focus();
});
let themeDone = false;
function commitTheme(){
if(themeDone) return; themeDone = true;
const nm = edThemeName.value.trim();
if(nm){
THEMES[S.lang].push({name:nm, words:[]});
edTheme = THEMES[S.lang].length-1;
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
}
edThemeName.style.display = "none";
setTimeout(()=>{ themeDone = false; }, 100);
}
edThemeName.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commitTheme();
if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); }
});
edThemeName.addEventListener("blur", commitTheme);
edThemeDel.addEventListener("click", ()=>{
if(edTheme==="g" || !THEMES[S.lang].length) return;
THEMES[S.lang].splice(edTheme,1);
edTheme = "g";
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
});
/* 🗂 opent de mappenverkenner voor thema's: zelfde Windows-stijl verkenner
als voor de borden (explorer.js), met de thema's van de actieve taal.
Nesting zit in het folder-veld als pad met "/"-scheiding. Ankerthema's
(id "an-...") staan vast in de map "⚓" - anchor.js zet die toch terug. */
function themesExplorerAdapter(){
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
const join = p => p.join("/");
const themes = ()=>THEMES[S.lang];
const isAnchor = th => typeof th.id === "string" && th.id.startsWith("an-");
/* mappen zijn afgeleid van themavelden; nieuwe lege mappen bestaan
sessie-lokaal tot er een thema in wordt gezet */
const pending = new Set();
const changed = ()=>{
persistWords();
document.dispatchEvent(new CustomEvent("wordschange"));
};
return {
title: ()=>T("themeFolder"),
allowRootItems: true,
newItemLabel: ()=>T("newTheme"),
list(path){
const p = join(path);
const folderMap = new Map();
const items = [];
const seen = new Set(pending);
themes().forEach((th, i)=>{
const j = join(split(th.folder));
if(j === p){
items.push({
key: i, name: `${th.name} (${th.words.length})`,
icon: isAnchor(th) ? "⚓" : "★",
movable: !isAnchor(th), deletable: !isAnchor(th),
active: edTheme === i,
});
}else if(!p || j.startsWith(p + "/")){
seen.add(j);
}
});
seen.forEach(j=>{
if(!(j === p || (!p && j) || j.startsWith(p + "/"))) return;
if(j === p) return;
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
if(!seg) return;
const under = themes().filter(t=>{ const tj = join(split(t.folder)); const full = p ? p+"/"+seg : seg; return tj === full || tj.startsWith(full + "/"); }).length;
folderMap.set(seg, under);
});
const folders = [...folderMap].map(([name, count])=>({name, count, fixed: name === "⚓" && !p}))
.sort((a,b)=>a.name.localeCompare(b.name));
return { folders, items };
},
createFolder(path, name){
const full = join([...path, name]);
if(this.list(path).folders.some(f=>f.name===name)) return T("exExists");
pending.add(full);
return null;
},
renameFolder(path, oldName, newName){
return this.moveFolder([...path, oldName], path, newName);
},
moveFolder(srcPath, targetPath, newName){
const from = join(srcPath);
if(from === "⚓") return T("exFixedFolder");
const to = join([...targetPath, newName || srcPath[srcPath.length-1]]);
if(from === to) return null;
if(themes().some(t=>{ const j = join(split(t.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists");
themes().forEach(t=>{
const j = join(split(t.folder));
if(j === from || j.startsWith(from + "/")) t.folder = to + j.slice(from.length);
});
[...pending].forEach(j=>{
if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); }
});
changed();
return null;
},
deleteFolder(path, name){
const full = join([...path, name]);
if(full === "⚓") return T("exFixedFolder");
if(themes().some(t=>{ const j = join(split(t.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty");
[...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); });
return null;
},
createItem(path){
const nm = (S.lang==="nl" ? "Thema " : "Theme ") + (themes().length + 1);
const th = { name: nm, words: [] };
if(path.length) th.folder = join(path);
themes().push(th);
pending.delete(join(path));
changed();
return null;
},
renameItem(i, name){
const th = themes()[i];
if(!th || isAnchor(th)) return isAnchor(th) ? T("exFixedFolder") : null;
th.name = name;
changed();
return null;
},
moveItem(i, targetPath){
const th = themes()[i];
if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null;
if(targetPath.length) th.folder = join(targetPath); else delete th.folder;
pending.delete(join(targetPath));
changed();
return null;
},
deleteItem(i){
const th = themes()[i];
if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null;
themes().splice(i, 1);
if(edTheme === i) edTheme = "g";
else if(edTheme !== "g" && edTheme > i) edTheme--;
changed();
return null;
},
openItem(i){
if(themes()[i]) edTheme = i;
},
};
}
edThemeMv.addEventListener("click", ()=>{
openExplorer(themesExplorerAdapter(), ()=>{ renderThemeSel(); renderEdList(); });
});
function selectPic(p, btn){
edPic = p; setPic(edPrev, p);
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
if(btn) btn.classList.add("sel");
}
/* tabs */
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); });
});
function refreshTabs(){
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1));
b.classList.toggle("active", b.dataset.t===edTab);
});
el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none";
el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none";
el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none";
el(".ed-p-library").style.display = edTab==="library" ? "block" : "none";
if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim();
}
/* emoji tab */
function renderEmojiCats(){
const cats = el(".ed-cats");
cats.innerHTML = "";
T("cats").forEach((name,i)=>{
const b = document.createElement("button");
b.textContent = name;
b.classList.toggle("active", i===edCat);
b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); });
cats.appendChild(b);
});
const grid = el(".ed-grid");
grid.innerHTML = "";
EMOJI_CATS[edCat].forEach(em=>{
const b = document.createElement("button");
b.type="button"; b.textContent = em;
b.classList.toggle("sel", em===edPic);
b.addEventListener("click", ()=>selectPic(em, b));
grid.appendChild(b);
});
}
/* picto tab (ARASAAC) */
async function searchPictos(){
const term = edPSearch.value.trim();
if(!term) return;
edStatus.textContent = T("pictoLoad");
edPGrid.innerHTML = "";
try{
const arr = await arasaacSearch(term, S.lang);
if(!arr.length){ edStatus.textContent = T("pictoNone"); return; }
edStatus.textContent = "";
arr.slice(0,12).forEach(p=>{
const url = arasaacUrl(p._id);
const b = document.createElement("button");
b.type="button";
const im = document.createElement("img");
im.src = url; im.loading = "lazy"; im.alt = term;
b.appendChild(im);
b.addEventListener("click", ()=>selectPic(url, b));
edPGrid.appendChild(b);
});
}catch(e){ edStatus.textContent = T("pictoErr"); }
}
el(".ed-pgo").addEventListener("click", searchPictos);
edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
el(".ed-open-library").addEventListener("click", ()=>{
openImageCatalog((src,image)=>selectPic(image?.value||src));
});
/* typing a word auto-fills picto search */
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
/* upload tab */
edFile.addEventListener("change", async ()=>{
const f = edFile.files[0]; if(!f) return;
edFile.value = "";
if(currentUser){
const hint=el(".ed-uphint"),old=hint.textContent;
hint.textContent=T("imgUploading");
try{
const image=await uploadImageToCatalog(f,{name:edWord.value.trim(),folder:"Taal/Mijn woordkaarten"});
selectPic(image.src);
}catch(err){hint.textContent=err.message;setTimeout(()=>{hint.textContent=old},3500)}
return;
}
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
const s = Math.min(1, 128/Math.max(img.width, img.height));
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
selectPic(c.toDataURL("image/png"));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
});
el(".ed-add").addEventListener("click", ()=>{
const w = edWord.value.trim().toLowerCase();
if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; }
if(!edPic){ edMsg.textContent = T("needPic"); return; }
edMsg.textContent = "";
if(edTheme==="g"){
GENERAL_EXTRA[S.lang].push([w, edPic]); /* extend the general card */
}else{
THEMES[S.lang][edTheme].words.push([w, edPic]);
}
persistWords();
renderThemeSel();
edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?";
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
renderEdList();
play(sndGood);
document.dispatchEvent(new CustomEvent("wordschange"));
});
el(".lg-ed-close").addEventListener("click", ()=>{
edEl.classList.remove("open");
buildCardButtons();
newRound();
render();
});
function openEditor(){
el(".ed-title").textContent = T("manage");
el(".ed-uplbl").textContent = T("upload");
el(".ed-uphint").textContent = T("upHint");
el(".ed-open-library").textContent = "🗂 "+T("imgOpenLibrary");
el(".ed-library-hint").textContent = T("imgLanguageHint");
el(".ed-credit").innerHTML = T("pictoCredit");
el(".ed-pgo").textContent = T("pictoSearch");
edPSearch.placeholder = T("pictoPh");
edWord.placeholder = T("wordPh");
el(".ed-add").textContent = T("addWord");
el(".lg-ed-close").title = T("close");
edMsg.textContent = ""; edStatus.textContent = "";
refreshTabs();
renderEmojiCats();
renderThemeSel();
renderEdList();
edEl.classList.add("open");
}
function renderEdList(){
edList.innerHTML = "";
const isGen = edTheme==="g";
const base = isGen ? WORDS[S.lang] : [];
const list = isGen ? GENERAL_EXTRA[S.lang] : (THEMES[S.lang][edTheme] ? THEMES[S.lang][edTheme].words : []);
if(!base.length && !list.length){
edList.innerHTML = `<div class="empty">${T("noWords")}</div>`;
return;
}
const addRow = (entry, delFn)=>{
const row = document.createElement("div");
row.className = "ed-row" + (delFn ? "" : " base");
const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]);
const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0];
row.appendChild(p); row.appendChild(wv);
if(delFn){
const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕";
del.addEventListener("click", delFn);
row.appendChild(del);
}
edList.appendChild(row);
};
/* own words first (deletable), built-in general words below (fixed) */
list.forEach((entry, i)=>addRow(entry, ()=>{
list.splice(i,1);
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
}));
base.forEach(entry=>addRow(entry, null));
}
/* ---------- drag & drop ----------
Hit-testing is geometric (cursor vs slot rectangles) instead of
elementFromPoint, so overlays/transforms can never break drops. */
function attachDrag(block){
block.addEventListener("pointerdown", e=>{
if(block.classList.contains("in-slot")) return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
/* avoid implicit touch capture retargeting the follow-up click */
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
const clone = block.cloneNode(true);
clone.id = "dragClone";
document.body.appendChild(clone);
block.classList.add("dragging");
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
moveTo(e);
let hovered = null;
const hitSlot = ev=>{
const pad = 8;
let found = null;
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
const r = s.getBoundingClientRect();
if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad &&
ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s;
});
return found;
};
const move = ev=>{
moveTo(ev);
const slot = hitSlot(ev);
if(hovered && hovered!==slot) hovered.classList.remove("droptarget");
hovered = slot;
if(hovered) hovered.classList.add("droptarget");
};
const up = ev=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
const target = hitSlot(ev);
if(target){
if(target.dataset.letter === block.dataset.letter){
target.classList.add("filled");
block.classList.add("in-slot");
block.dataset.placedAt = Date.now();
target.appendChild(block);
play(sndGood);
checkComplete();
}else{
S.wordMistakes.push({expected: target.dataset.letter, given: block.dataset.letter});
target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad);
}
}
};
const cancel = ()=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
};
document.addEventListener("pointermove",move);
document.addEventListener("pointerup",up);
document.addEventListener("pointercancel",cancel);
});
}
buildNumSel();
const assignedRules=languageTaskRules(S.yearGroup,S.level);
if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.letterLevel;
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
updateSndBtn();
newRound();
render();
document.addEventListener("langchange", updateSndBtn);
return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; S.assignmentWords=Array.isArray(st.assignmentWords)?st.assignmentWords:null; S.assignmentThemeName=typeof st.assignmentThemeName==="string"?st.assignmentThemeName:""; buildCardButtons(); buildNumSel(); updateSndBtn(); newRound(); render(); } }
};
}
function confetti(container){
const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"];
const rect = container.getBoundingClientRect();
for(let i=0;i<28;i++){
const c = document.createElement("div");
c.className = "confetti";
c.style.left = Math.random()*rect.width + "px";
c.style.background = colors[i%colors.length];
c.style.animationDuration = (1.2+Math.random()*1.4)+"s";
c.style.animationDelay = (Math.random()*0.3)+"s";
container.appendChild(c);
setTimeout(()=>c.remove(), 3200);
}
}