Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
5 changed files with 223 additions and 10 deletions
|
|
@ -887,6 +887,13 @@
|
||||||
justify-content:center; font-size:16px; line-height:1;
|
justify-content:center; font-size:16px; line-height:1;
|
||||||
}
|
}
|
||||||
.an-pic img{width:100%; height:100%; object-fit:contain;}
|
.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{
|
.an-dot{
|
||||||
position:absolute; right:4px; bottom:4px; width:15px; height:15px; border-radius:50%;
|
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;
|
border:1.5px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.3); cursor:pointer; padding:0;
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@
|
||||||
<script src="js/core.js"></script>
|
<script src="js/core.js"></script>
|
||||||
<script src="js/permissions.js"></script>
|
<script src="js/permissions.js"></script>
|
||||||
<script src="js/data.js"></script>
|
<script src="js/data.js"></script>
|
||||||
|
<script src="js/picker.js"></script>
|
||||||
<script src="js/widgets/letters.js"></script>
|
<script src="js/widgets/letters.js"></script>
|
||||||
<script src="js/widgets/write.js"></script>
|
<script src="js/widgets/write.js"></script>
|
||||||
<script src="js/widgets/math.js"></script>
|
<script src="js/widgets/math.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ const I18N = {
|
||||||
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
|
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
|
||||||
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
|
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
|
||||||
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen",
|
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",
|
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
|
||||||
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
|
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
|
||||||
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
|
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 ▸",
|
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
|
||||||
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
|
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
|
||||||
myWords:"My words", manage:"Manage themes and words", addWord:"+ Add",
|
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",
|
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
|
||||||
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
|
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
|
||||||
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
|
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
|
||||||
|
|
|
||||||
162
public/js/picker.js
Normal file
162
public/js/picker.js
Normal file
|
|
@ -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 = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="am-head">
|
||||||
|
<h2 data-i18n="pickPic"></h2>
|
||||||
|
<button class="wclose pp-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="ed-tabs">
|
||||||
|
<button type="button" data-t="emoji"></button>
|
||||||
|
<button type="button" data-t="picto"></button>
|
||||||
|
<button type="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>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ function mountAnchor(root, initState){
|
||||||
const CW = 128, CH = 56, G = 10;
|
const CW = 128, CH = 56, G = 10;
|
||||||
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
|
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
|
||||||
const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"];
|
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){
|
if(initState){
|
||||||
const src = initState.cells || {};
|
const src = initState.cells || {};
|
||||||
Object.keys(src).forEach(k=>{
|
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.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.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();
|
const fetching = new Set();
|
||||||
/* auto-fetch a matching picture per word instead of using the anchor emoji for everything */
|
|
||||||
function fetchPic(word){
|
function fetchPic(word){
|
||||||
if(S.pics[word] !== undefined || fetching.has(word)) return;
|
if(S.pics[word] !== undefined || fetching.has(word)) return;
|
||||||
fetching.add(word);
|
fetching.add(word);
|
||||||
arasaacSearch(word, LANG).then(arr=>{
|
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(()=>{
|
}).catch(()=>{
|
||||||
S.pics[word] = FALLBACK_PIC;
|
S.pics[word] = null;
|
||||||
}).finally(()=>{
|
}).finally(()=>{
|
||||||
fetching.delete(word);
|
fetching.delete(word);
|
||||||
syncSource();
|
syncSource();
|
||||||
render();
|
render();
|
||||||
|
persistWords();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
|
|
@ -46,6 +51,22 @@ function mountAnchor(root, initState){
|
||||||
/* register this grid as a word source for the language widgets */
|
/* register this grid as a word source for the language widgets */
|
||||||
const reg = { el: root, name: T("wg_anchor"), words: [] };
|
const reg = { el: root, name: T("wg_anchor"), words: [] };
|
||||||
ANCHORS.push(reg);
|
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){
|
function syncSource(quiet){
|
||||||
const center = S.cells["0,0"];
|
const center = S.cells["0,0"];
|
||||||
reg.name = (center && center.t) ? center.t : T("wg_anchor");
|
reg.name = (center && center.t) ? center.t : T("wg_anchor");
|
||||||
|
|
@ -53,12 +74,13 @@ function mountAnchor(root, initState){
|
||||||
reg.words = [];
|
reg.words = [];
|
||||||
Object.keys(S.cells).forEach(k=>{
|
Object.keys(S.cells).forEach(k=>{
|
||||||
const w = (S.cells[k].t||"").trim().toLowerCase();
|
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);
|
seen.add(w);
|
||||||
if(S.pics[w] === undefined) fetchPic(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"));
|
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.left = (cx + gx*(CW+G) - CW/2)+"px";
|
||||||
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
|
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
|
||||||
if(word){
|
if(word){
|
||||||
|
const normWord = word.trim().toLowerCase();
|
||||||
|
const wPic = S.pics[normWord];
|
||||||
const pic = document.createElement("div");
|
const pic = document.createElement("div");
|
||||||
pic.className = "an-pic";
|
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);
|
c.appendChild(pic);
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "an-word";
|
span.className = "an-word";
|
||||||
|
|
@ -177,6 +218,6 @@ function mountAnchor(root, initState){
|
||||||
syncSource(true);
|
syncSource(true);
|
||||||
render();
|
render();
|
||||||
if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange"));
|
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 }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue