teach/public/js/widgets/letters.js

794 lines
31 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;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
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";
}
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{
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";
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; S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
});
document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render();
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
});
function fullList(){
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();
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");
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 });
}
}
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{
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"; 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);
}
}