All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
- Nieuwe tabel `assignments`: een leerkracht koppelt één van haar eigen borden aan een klas (standaard) of een individuele leerling (uitzondering, voor wie op een ander niveau werkt). - Geen momentopname: /my/assignment zoekt het bord live op bij de leerkracht, dus nieuwe woorden die zij toevoegt komen vanzelf door zodra de leerling-pagina opnieuw pollt (elke 20s). - Leerlingen loggen in op een vereenvoudigde pagina zonder whiteboard/ tekengereedschap: enkel de toegewezen taal-/rekenwidgets, als vaste (niet-sleepbare) kaarten in speelmodus. - Speelmodus verbergt bewerkfuncties in Ankerwoorden (plaatje kiezen, woord typen, kleur wijzigen) en Letterblokken (thema/woordbeheer) - overige widgets waren al zuiver speelgericht. - Elk bord krijgt een stabiel id (board.js) zodat een toewijzing een bord blijft vinden ook na hernoemen of herordenen. - Nieuw tabblad "Toewijzingen" in het beheerpaneel: per klas of leerling een eigen bord kiezen, met een expliciete toewijs-stap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuxHJtk5aHe2pA3xwCLEAk
617 lines
23 KiB
JavaScript
617 lines
23 KiB
JavaScript
/* widget: Letterblokken (Ik leer lezen) */
|
||
function mountLetterGame(root, initState, opts){
|
||
const readonly = !!(opts && opts.readonly);
|
||
const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
|
||
if(initState){
|
||
S.level = initState.level||1;
|
||
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-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>
|
||
</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>
|
||
<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);
|
||
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(); });
|
||
});
|
||
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);
|
||
return wordSourceList(S.card);
|
||
}
|
||
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();
|
||
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(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");
|
||
}
|
||
}
|
||
function showCardDone(){
|
||
doneEl.querySelector(".dt").textContent = T("cardDone");
|
||
doneEl.querySelector(".dp").textContent = T("pickCard");
|
||
doneEl.classList.add("show");
|
||
nextBtn.classList.remove("show");
|
||
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");
|
||
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("cardGeneral")} (${generalList().length})`;
|
||
edThemeSel.appendChild(og);
|
||
list.forEach((th,i)=>{
|
||
const o = document.createElement("option");
|
||
o.value = i; o.textContent = `★ ${th.name} (${th.words.length})`;
|
||
edThemeSel.appendChild(o);
|
||
});
|
||
edThemeSel.value = edTheme;
|
||
edThemeAdd.title = T("newTheme");
|
||
edThemeDel.title = T("delTheme");
|
||
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"));
|
||
});
|
||
|
||
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";
|
||
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(); });
|
||
/* typing a word auto-fills picto search */
|
||
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
|
||
|
||
/* upload tab */
|
||
edFile.addEventListener("change", ()=>{
|
||
const f = edFile.files[0]; if(!f) 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);
|
||
edFile.value = "";
|
||
});
|
||
|
||
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-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();
|
||
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.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);
|
||
}
|
||
}
|
||
|
||
|