teach/public/js/widgets/letters.js
Ramon 50b9f32e91
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m56s
feat: voeg voortgangdetail toe aan de taalwidgets (v0.4.63-beta)
Gewijzigde functionaliteiten:

- Letterblokken logt per afgerond woord hoeveel pogingen het kostte en welke
  letter op welke plek fout ging (beide invoerwijzen: blokken slepen en typen).

- Ballonnenwoord logt nu ook een verloren woord; dat leverde tot nu toe geen
  enkele voortgang op. Een gewonnen woord telt de fout geraden letters mee.

- Flitswoorden stuurt de bestaande zelfcheck ("Gelukt" / "Nog oefenen") door
  naar de voortgang; die uitkomst werd eerder weggegooid.

- Hakken & plakken krijgt dezelfde zelfcheck en daarmee als laatste taalwidget
  een goed/fout-moment; de oefening zelf verandert niet.

- Voortgang-tab toont de fouten van taal: naast fout afgesloten opdrachten ook
  "na N pogingen goed" (letterblokken kent geen faalmoment) en zelfchecks.

- Reeksstappen met flitswoorden of hakken & plakken wachten nu op een echt
  afgerond moment in plaats van meteen te completeren.

Tests: 192 geslaagd (7 nieuw), plus live nagelopen in de leerlingweergave met
echte interactie per widget en een screenshot van de Voortgang-tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126hjBPD2ggZ2LEoDYZSW7S
2026-07-26 03:16:28 +02:00

811 lines
33 KiB
JavaScript
Raw 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">
<button class="lg-snd"></button>
<button class="lg-mode"></button>
<div class="lg-levels">
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
</div>
<div class="lg-cards"></div>
<select class="lg-num"></select>
<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");
/* card buttons (built dynamically: N cards + star + edit) */
const numSel = root.querySelector(".lg-num");
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();
});
const sndBtn = root.querySelector(".lg-snd");
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");
}
const modeBtn = root.querySelector(".lg-mode");
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);
}
}