/* 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 = `
🌟
?
`; 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 = `
${letter}
`; 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 = `
${showGhost ? letter : ""}
`; 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 = `
${T("noWords")}
`; 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); } }