From 270739da093e85ac99a7f205600ebb8d59aa935f Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 19:28:05 +0200 Subject: [PATCH] v0.2.16-beta: ankerwoorden - lengtegrens-bug, geen nep-ankersymbool, koppeling met woordkaart - Lengte-check voor ankerwoorden was strenger dan het invoerveld zelf toestond ({2,12} vs maxLength 24): woorden als "kledingwinkel" werden nooit opgezocht terwijl ARASAAC er prima plaatjes voor heeft - Geen ankersymbool meer als terugval wanneer ARASAAC geen match heeft (bv. "contant"); de cel blijft leeg en is klikbaar als snelkoppeling naar een nieuwe, herbruikbare plaatjeskiezer (nieuw: public/js/picker.js, hergebruikt dezelfde emoji-lijst/ARASAAC-zoekfunctie/CSS als de bestaande woordkaart-editor) - Elk ankerraster synchroniseert zijn woorden-met-plaatje voortaan naar een eigen thema in THEMES, zodat ankerwoorden ook beheerbaar zijn via de bestaande woordkaart-editor (Letterblokken-widget) - geen wijziging aan letters.js nodig, het bestaande thema-systeem pakt dit vanzelf op - Oudere borden met het letterlijke ankersymbool als opgeslagen "plaatje" migreren automatisch naar de nieuwe lege staat Co-Authored-By: Claude Sonnet 5 --- public/css/teach.css | 7 ++ public/index.html | 1 + public/js/core.js | 2 + public/js/picker.js | 162 ++++++++++++++++++++++++++++++++++++ public/js/widgets/anchor.js | 61 +++++++++++--- 5 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 public/js/picker.js diff --git a/public/css/teach.css b/public/css/teach.css index f60166e..7ec5aff 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -887,6 +887,13 @@ justify-content:center; font-size:16px; line-height:1; } .an-pic img{width:100%; height:100%; object-fit:contain;} + /* geen automatisch plaatje (nog) - lege, klikbare plek i.p.v. een nep-ankersymbool, + dient als snelkoppeling naar de plaatjeskiezer */ + .an-pic-empty{ + border:1.5px dashed #cdd7e4; border-radius:6px; cursor:pointer; opacity:.6; + transition:opacity .15s, border-color .15s; + } + .an-pic-empty:hover{opacity:1; border-color:var(--blue);} .an-dot{ position:absolute; right:4px; bottom:4px; width:15px; height:15px; border-radius:50%; border:1.5px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.3); cursor:pointer; padding:0; diff --git a/public/index.html b/public/index.html index c573ad3..db59f51 100644 --- a/public/index.html +++ b/public/index.html @@ -150,6 +150,7 @@ + diff --git a/public/js/core.js b/public/js/core.js index fb52143..2d42907 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -47,6 +47,7 @@ const I18N = { card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸", cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.", myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen", + pickPic:"Kies een plaatje", newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen", numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", @@ -173,6 +174,7 @@ const I18N = { card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸", cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.", myWords:"My words", manage:"Manage themes and words", addWord:"+ Add", + pickPic:"Choose a picture", newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme", numWords:"Words per game", all:"All", noThemes:"— no themes yet —", muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", diff --git a/public/js/picker.js b/public/js/picker.js new file mode 100644 index 0000000..f06ca51 --- /dev/null +++ b/public/js/picker.js @@ -0,0 +1,162 @@ +/* teach - herbruikbare plaatjeskiezer (emoji / ARASAAC-zoeken / eigen upload). + Bouwt voort op dezelfde bouwstenen als de woordkaart-editor in letters.js + (EMOJI_CATS, arasaacSearch/arasaacUrl, setPic, dezelfde CSS-klassen) i.p.v. + een tweede plaatjessysteem te maken. Gebruikt door widgets die snel een los + plaatje willen laten kiezen zonder de volledige woordkaart-editor te openen + (bv. Ankerwoorden, als snelkoppeling vanaf een woord zonder automatisch plaatje). */ +"use strict"; +let pickerWrap = null, pickerCat = 0, pickerOnSelect = null; + +function choosePic(pic){ + if(pickerOnSelect) pickerOnSelect(pic); + closePicPicker(); +} +function closePicPicker(){ + if(pickerWrap) pickerWrap.classList.remove("open"); + pickerOnSelect = null; +} + +function ensurePicPicker(){ + if(pickerWrap) return pickerWrap; + const wrap = document.createElement("div"); + wrap.className = "modalwrap"; + wrap.innerHTML = ` + `; + document.body.appendChild(wrap); + pickerWrap = wrap; + const q = s => wrap.querySelector(s); + + wrap.addEventListener("click", e=>{ if(e.target===wrap) closePicPicker(); }); + q(".pp-close").addEventListener("click", closePicPicker); + + let tab = "emoji"; + function setTab(t){ tab = t; refreshTabs(); } + function applyLabels(){ + q(".ed-uplbl").textContent = T("upload"); + q(".ed-uphint").textContent = T("upHint"); + q(".ed-credit").innerHTML = T("pictoCredit"); + q(".ed-pgo").textContent = T("pictoSearch"); + q(".ed-psearch").placeholder = T("pictoPh"); + q(".pp-close").title = T("close"); + refreshTabs(); + } + function refreshTabs(){ + wrap.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===tab); + }); + q(".ed-p-emoji").style.display = tab==="emoji" ? "block" : "none"; + q(".ed-p-picto").style.display = tab==="picto" ? "block" : "none"; + q(".ed-p-upload").style.display = tab==="upload" ? "block" : "none"; + } + wrap.querySelectorAll(".ed-tabs button").forEach(b=>{ + b.addEventListener("click", ()=>{ tab = b.dataset.t; refreshTabs(); }); + }); + + function renderCats(){ + const cats = q(".ed-cats"); + cats.innerHTML = ""; + T("cats").forEach((name,i)=>{ + const b = document.createElement("button"); + b.type = "button"; b.textContent = name; + b.classList.toggle("active", i===pickerCat); + b.addEventListener("click", ()=>{ pickerCat=i; renderCats(); }); + cats.appendChild(b); + }); + const grid = q(".ed-grid"); + grid.innerHTML = ""; + EMOJI_CATS[pickerCat].forEach(em=>{ + const b = document.createElement("button"); + b.type = "button"; b.textContent = em; + b.addEventListener("click", ()=>choosePic(em)); + grid.appendChild(b); + }); + } + + async function searchPictos(){ + const term = q(".ed-psearch").value.trim(); + if(!term) return; + q(".ed-status").textContent = T("pictoLoad"); + q(".ed-pgrid").innerHTML = ""; + try{ + const arr = await arasaacSearch(term, LANG); + if(!arr.length){ q(".ed-status").textContent = T("pictoNone"); return; } + q(".ed-status").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", ()=>choosePic(url)); + q(".ed-pgrid").appendChild(b); + }); + }catch(e){ q(".ed-status").textContent = T("pictoErr"); } + } + q(".ed-pgo").addEventListener("click", searchPictos); + q(".ed-psearch").addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); }); + + q(".ed-p-upload input").addEventListener("change", e=>{ + const f = e.target.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); + choosePic(c.toDataURL("image/png")); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(f); + e.target.value = ""; + }); + + wrap.__applyLabels = applyLabels; + wrap.__renderCats = renderCats; + wrap.__searchPictos = searchPictos; + wrap.__setTab = setTab; + return wrap; +} + +function openPicPicker(word, onSelect){ + const wrap = ensurePicPicker(); + pickerOnSelect = onSelect; + wrap.__applyLabels(); + wrap.__renderCats(); + wrap.querySelector(".ed-psearch").value = word || ""; + /* met een woord starten we meteen op het picto-zoekresultaat i.p.v. het emoji-tabblad, + anders lijkt de vooraf ingevulde zoekopdracht nergens naartoe te leiden */ + wrap.__setTab(word ? "picto" : "emoji"); + wrap.classList.add("open"); + if(word) wrap.__searchPictos(); +} diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index d6d4f9a..d63b038 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -6,7 +6,7 @@ function mountAnchor(root, initState){ const CW = 128, CH = 56, G = 10; /* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */ const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"]; - const S = { cells:{}, pan:{x:0, y:0}, pics:{} }; + const S = { cells:{}, pan:{x:0, y:0}, pics:{}, themeId:null }; if(initState){ const src = initState.cells || {}; Object.keys(src).forEach(k=>{ @@ -15,22 +15,27 @@ function mountAnchor(root, initState){ S.cells[k] = (typeof v==="string") ? {t:v, c:null} : {t:v.t||"", c:v.c||null}; }); S.pan = initState.pan || {x:0, y:0}; - S.pics = initState.pics || {}; /* word -> auto-fetched pictogram (or fallback emoji) */ + S.pics = initState.pics || {}; /* word -> resolved picture (undefined/null/URL) */ + /* migrate: older boards stored the literal anchor emoji as a "picture" - treat + that the same as "no match found" now that it's no longer shown as a picture */ + Object.keys(S.pics).forEach(w=>{ if(S.pics[w] === "⚓") S.pics[w] = null; }); + S.themeId = initState.themeId || null; } - const FALLBACK_PIC = "⚓"; + /* S.pics[word]: undefined = not tried yet, null = tried, no ARASAAC match + (shown empty, no fake anchor icon), string = resolved picture URL */ const fetching = new Set(); - /* auto-fetch a matching picture per word instead of using the anchor emoji for everything */ function fetchPic(word){ if(S.pics[word] !== undefined || fetching.has(word)) return; fetching.add(word); arasaacSearch(word, LANG).then(arr=>{ - S.pics[word] = (arr && arr.length) ? arasaacUrl(arr[0]._id) : FALLBACK_PIC; + S.pics[word] = (arr && arr.length) ? arasaacUrl(arr[0]._id) : null; }).catch(()=>{ - S.pics[word] = FALLBACK_PIC; + S.pics[word] = null; }).finally(()=>{ fetching.delete(word); syncSource(); render(); + persistWords(); }); } root.innerHTML = ` @@ -46,6 +51,22 @@ function mountAnchor(root, initState){ /* register this grid as a word source for the language widgets */ const reg = { el: root, name: T("wg_anchor"), words: [] }; ANCHORS.push(reg); + /* mirror words that already have a real picture into a linked theme, so the + existing woordkaart-editor (letters.js) can manage/fix their pictures too - + the anchor grid stays the source of truth for which words exist */ + function syncTheme(){ + if(!S.themeId) S.themeId = "an-"+Math.random().toString(36).slice(2,10); + const withPic = reg.words.filter(([,p]) => typeof p === "string"); + const list = THEMES[LANG]; + const idx = list.findIndex(t => t.id === S.themeId); + if(!withPic.length){ + if(idx >= 0) list[idx].words = []; + return; + } + const name = "⚓ " + reg.name; + if(idx < 0) list.push({ id: S.themeId, name, words: withPic.slice() }); + else{ list[idx].name = name; list[idx].words = withPic.slice(); } + } function syncSource(quiet){ const center = S.cells["0,0"]; reg.name = (center && center.t) ? center.t : T("wg_anchor"); @@ -53,12 +74,13 @@ function mountAnchor(root, initState){ reg.words = []; Object.keys(S.cells).forEach(k=>{ const w = (S.cells[k].t||"").trim().toLowerCase(); - if(/^[a-zà-ÿ]{2,12}$/.test(w) && !seen.has(w)){ + if(/^[a-zà-ÿ]{2,24}$/.test(w) && !seen.has(w)){ seen.add(w); if(S.pics[w] === undefined) fetchPic(w); - reg.words.push([w, S.pics[w] || FALLBACK_PIC]); + reg.words.push([w, S.pics[w] ?? null]); } }); + syncTheme(); if(!quiet) document.dispatchEvent(new CustomEvent("wordschange")); } @@ -80,9 +102,28 @@ function mountAnchor(root, initState){ c.style.left = (cx + gx*(CW+G) - CW/2)+"px"; c.style.top = (cy + gy*(CH+G) - CH/2)+"px"; if(word){ + const normWord = word.trim().toLowerCase(); + const wPic = S.pics[normWord]; const pic = document.createElement("div"); pic.className = "an-pic"; - setPic(pic, S.pics[word.trim().toLowerCase()] || FALLBACK_PIC); + if(wPic){ + setPic(pic, wPic); + }else{ + /* no automatic match (or not tried yet) - stays empty, but doubles as a + shortcut to manually pick a picture instead of showing a fake anchor icon */ + pic.classList.add("an-pic-empty"); + pic.title = T("pickPic"); + pic.addEventListener("pointerdown", ev=>ev.stopPropagation()); + pic.addEventListener("click", ev=>{ + ev.stopPropagation(); + openPicPicker(normWord, chosen=>{ + S.pics[normWord] = chosen; + syncSource(); + render(); + persistWords(); + }); + }); + } c.appendChild(pic); const span = document.createElement("span"); span.className = "an-word"; @@ -177,6 +218,6 @@ function mountAnchor(root, initState){ syncSource(true); render(); if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange")); - return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics }) }; + return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics, themeId:S.themeId }) }; }