All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- Oorzaak van het "vanzelf" openende uploadvenster gevonden: elk leeg plaatjesvak in een ankerdiagram had eigen pointer/click-handlers die het pannen blokkeerden en de plaatjeskiezer (met uploadtab) openden bij elke veeg die erop begon - op een telefoon was dat vrijwel elke aanraking, dus ook direct na het verversen - Lege plaatjesvakken lopen nu via dezelfde tik/sleep-logica als de cellen (6px-drempel): vegen pant het diagram, alleen een stilstaande tik opent de kiezer; woord bewerken blijft ongewijzigd - Tekenbalk op telefoons weer gecentreerd op inhoudsbreedte i.p.v. vastgepind aan beide randen: geminimaliseerd is hij weer smal (121px i.p.v. volle breedte), nog steeds in de eigen laag boven +/borden - Hardening: een onbekende achtergrond-waarde in opgeslagen borddata crashte de hydrate en logde de gebruiker stilletjes uit bij verversen - valt nu terug op de standaardachtergrond - Geverifieerd in iPhone-emulatie (8 checks): geen venster na verversen, veeg pant, tik kiest, tekenbalk smal en vrij van + Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
412 lines
16 KiB
JavaScript
412 lines
16 KiB
JavaScript
/* widget: Ankerwoorden */
|
|
/* =========================================================
|
|
Anchor words — unlimited word grid around a theme
|
|
==========================================================*/
|
|
function mountAnchor(root, initState, opts){
|
|
const readonly = !!(opts && opts.readonly);
|
|
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:{}, themeId:null };
|
|
if(initState){
|
|
const src = initState.cells || {};
|
|
Object.keys(src).forEach(k=>{
|
|
const v = src[k];
|
|
/* migrate: old format stored plain strings */
|
|
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 -> 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;
|
|
}
|
|
/* 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();
|
|
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) : null;
|
|
}).catch(()=>{
|
|
S.pics[word] = null;
|
|
}).finally(()=>{
|
|
fetching.delete(word);
|
|
syncSource();
|
|
render();
|
|
persistWords();
|
|
});
|
|
}
|
|
root.innerHTML = `
|
|
<div class="an">
|
|
<div class="an-hint"></div>
|
|
<div class="an-tools">
|
|
<button class="an-save" type="button">💾</button>
|
|
<button class="an-lib" type="button">🗂</button>
|
|
</div>
|
|
<div class="an-stage"><div class="an-layer"></div></div>
|
|
</div>`;
|
|
const stage = root.querySelector(".an-stage"),
|
|
layer = root.querySelector(".an-layer"),
|
|
hint = root.querySelector(".an-hint"),
|
|
toolsEl = root.querySelector(".an-tools"),
|
|
saveBtn = root.querySelector(".an-save"),
|
|
libBtn = root.querySelector(".an-lib");
|
|
if(readonly) toolsEl.remove();
|
|
const key = (x,y)=>x+","+y;
|
|
|
|
/* 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;
|
|
/* vaste map "⚓" zodat ankerthema's gegroepeerd staan in de editor- en
|
|
widget-dropdowns (zie de optgroup-weergave in letters.js/data.js) */
|
|
if(idx < 0) list.push({ id: S.themeId, name, folder: "⚓", words: withPic.slice() });
|
|
else{ list[idx].name = name; list[idx].folder = "⚓"; list[idx].words = withPic.slice(); }
|
|
}
|
|
function syncSource(quiet){
|
|
const center = S.cells["0,0"];
|
|
reg.name = (center && center.t) ? center.t : T("wg_anchor");
|
|
const seen = new Set();
|
|
reg.words = [];
|
|
Object.keys(S.cells).forEach(k=>{
|
|
const w = (S.cells[k].t||"").trim().toLowerCase();
|
|
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] ?? null]);
|
|
}
|
|
});
|
|
syncTheme();
|
|
if(!quiet) document.dispatchEvent(new CustomEvent("wordschange"));
|
|
}
|
|
|
|
function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; }
|
|
function render(){
|
|
layer.innerHTML = "";
|
|
const w = stage.offsetWidth, h = stage.offsetHeight;
|
|
if(w < 2) return;
|
|
const cx = w/2, cy = h/2;
|
|
const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G));
|
|
const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G));
|
|
for(let gx=x0; gx<=x1; gx++){
|
|
for(let gy=y0; gy<=y1; gy++){
|
|
const c = document.createElement("div");
|
|
const cell = S.cells[key(gx,gy)];
|
|
const word = cell && cell.t;
|
|
const isRoot = gx===0 && gy===0;
|
|
c.className = "an-cell" + (word ? " filled" : "") + (isRoot ? " root" : "");
|
|
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";
|
|
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.
|
|
Geen eigen pointer/click-handlers meer: die blokkeerden het pannen en
|
|
openden de kiezer bij elke veeg die op dit vak begon (op telefoons was
|
|
dat vrijwel elke aanraking). De tik loopt nu via de stage-logica
|
|
hieronder, mét de sleep-drempel. */
|
|
pic.classList.add("an-pic-empty");
|
|
if(!readonly) pic.title = T("pickPic");
|
|
}
|
|
c.appendChild(pic);
|
|
const span = document.createElement("span");
|
|
span.className = "an-word";
|
|
span.textContent = word;
|
|
c.appendChild(span);
|
|
/* only the theme word is styled by default; other words are plain
|
|
until the teacher gives them a colour to show a connection */
|
|
if(!isRoot){
|
|
if(cell.c){
|
|
c.style.borderColor = cell.c;
|
|
c.style.background = cell.c + "1c";
|
|
}
|
|
if(!readonly){
|
|
const dot = document.createElement("button");
|
|
dot.className = "an-dot";
|
|
dot.style.background = cell.c || "#cdd7e4";
|
|
dot.title = T("anchorColor");
|
|
dot.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
|
dot.addEventListener("click", ev=>{
|
|
ev.stopPropagation();
|
|
cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length];
|
|
render();
|
|
});
|
|
c.appendChild(dot);
|
|
}
|
|
}
|
|
}
|
|
c.dataset.gx = gx; c.dataset.gy = gy;
|
|
layer.appendChild(c);
|
|
}
|
|
}
|
|
applyPan();
|
|
hint.textContent = T(readonly ? "anchorHintReadonly" : "anchorHint");
|
|
}
|
|
/* pan by dragging, click (without moving) to type */
|
|
let pd = null;
|
|
stage.addEventListener("pointerdown", e=>{
|
|
if(e.target.tagName==="INPUT") return;
|
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
|
e.preventDefault();
|
|
stage.setPointerCapture(e.pointerId);
|
|
const sc = stage.getBoundingClientRect().width / stage.offsetWidth;
|
|
pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y,
|
|
moved:false, target:e.target.closest(".an-cell"),
|
|
pic:e.target.closest(".an-pic-empty"), sc };
|
|
});
|
|
stage.addEventListener("pointermove", e=>{
|
|
if(!pd) return;
|
|
const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y;
|
|
if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true;
|
|
if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); }
|
|
});
|
|
stage.addEventListener("pointerup", ()=>{
|
|
if(!pd) return;
|
|
const {moved, target, pic} = pd;
|
|
pd = null;
|
|
if(moved){ render(); return; }
|
|
if(readonly || !target) return;
|
|
/* stilstaande tik op een leeg plaatjesvak: plaatje kiezen voor dat woord */
|
|
if(pic){
|
|
const cell = S.cells[key(+target.dataset.gx, +target.dataset.gy)];
|
|
const normWord = cell && cell.t && cell.t.trim().toLowerCase();
|
|
if(normWord){
|
|
openPicPicker(normWord, chosen=>{
|
|
S.pics[normWord] = chosen;
|
|
syncSource();
|
|
render();
|
|
persistWords();
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
editCell(target);
|
|
});
|
|
stage.addEventListener("pointercancel", ()=>{
|
|
const moved = pd && pd.moved;
|
|
pd = null;
|
|
if(moved) render();
|
|
});
|
|
function editCell(cell){
|
|
if(cell.querySelector("input")) return;
|
|
const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy);
|
|
const inp = document.createElement("input");
|
|
inp.maxLength = 24;
|
|
inp.value = S.cells[k] ? S.cells[k].t : "";
|
|
if(gx===0 && gy===0) inp.placeholder = T("anchorPh");
|
|
cell.textContent = "";
|
|
cell.appendChild(inp);
|
|
setTimeout(()=>{ inp.focus(); inp.select(); }, 0);
|
|
let done = false;
|
|
const commit = ()=>{
|
|
if(done) return; done = true;
|
|
const v = inp.value.trim();
|
|
if(v) S.cells[k] = { t:v, c:(S.cells[k] && S.cells[k].c) || null };
|
|
else delete S.cells[k];
|
|
syncSource();
|
|
render();
|
|
persistWords();
|
|
};
|
|
inp.addEventListener("keydown", ev=>{
|
|
ev.stopPropagation();
|
|
if(ev.key==="Enter") commit();
|
|
if(ev.key==="Escape"){ done = true; render(); }
|
|
});
|
|
inp.addEventListener("blur", commit);
|
|
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
|
}
|
|
/* een bewaard/gedeeld diagram in dít widget laden (dubbelklik in de
|
|
verkenner die via het 🗂-knopje van dit widget is geopend) */
|
|
function loadDiagram(entry){
|
|
S.cells = structuredClone(entry.cells || {});
|
|
S.pics = structuredClone(entry.pics || {});
|
|
S.pan = {x:0, y:0};
|
|
syncSource();
|
|
render();
|
|
persistWords();
|
|
/* programmatisch geladen: de bord-autosave-listeners zien dit niet */
|
|
markBoardChange();
|
|
return null;
|
|
}
|
|
if(!readonly){
|
|
saveBtn.title = T("anchorLibSave");
|
|
libBtn.title = T("anchorLibOpen");
|
|
saveBtn.addEventListener("click", ()=>{
|
|
if(!Object.keys(S.cells).length){ hint.textContent = T("anchorLibEmptyGrid"); return; }
|
|
ANCHOR_LIB.push({
|
|
id: "al-"+Math.random().toString(36).slice(2,10),
|
|
name: reg.name,
|
|
cells: structuredClone(S.cells),
|
|
pics: structuredClone(S.pics),
|
|
});
|
|
scheduleSave("anchors");
|
|
const orig = saveBtn.textContent;
|
|
saveBtn.textContent = "✓";
|
|
setTimeout(()=>{ saveBtn.textContent = orig; }, 1200);
|
|
});
|
|
libBtn.addEventListener("click", ()=>openExplorer(
|
|
withSharedBranches("anchor", anchorLibraryAdapter(loadDiagram), anchorLibrarySharedHooks(loadDiagram))));
|
|
}
|
|
|
|
new ResizeObserver(render).observe(stage);
|
|
document.addEventListener("langchange", render);
|
|
syncSource(true);
|
|
render();
|
|
if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange"));
|
|
return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics, themeId:S.themeId }) };
|
|
}
|
|
|
|
/* ---- ankerbibliotheek: diagrammen los van een bord bewaren en hergebruiken.
|
|
Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via de
|
|
Windows-stijl mappenverkenner. Op module-niveau zodat zowel het 🗂-knopje
|
|
op een widget (openen = in dát widget laden) als de vaste ⚓-tak in de
|
|
borden-verkenner (openen = nieuw widget op het huidige bord) dezelfde
|
|
adapter gebruiken - alleen de onOpen-actie verschilt. */
|
|
function anchorLibraryAdapter(onOpen){
|
|
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
|
|
const join = p => p.join("/");
|
|
const wordCount = it => Object.keys(it.cells||{}).length;
|
|
const pending = new Set();
|
|
const changed = ()=>scheduleSave("anchors");
|
|
return {
|
|
title: ()=>T("anchorLibTitle"),
|
|
allowRootItems: true,
|
|
list(path){
|
|
const p = join(path);
|
|
const folderMap = new Map();
|
|
const items = [];
|
|
const seen = new Set(pending);
|
|
ANCHOR_LIB.forEach((it, i)=>{
|
|
const j = join(split(it.folder));
|
|
if(j === p) items.push({ key: i, name: `${it.name} (${wordCount(it)})`, icon: "⚓" });
|
|
else if(!p || j.startsWith(p + "/")) seen.add(j);
|
|
});
|
|
seen.forEach(j=>{
|
|
if(j === p) return;
|
|
if(p && !j.startsWith(p + "/")) return;
|
|
if(!p && !j) return;
|
|
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
|
|
if(!seg) return;
|
|
const full = p ? p + "/" + seg : seg;
|
|
const under = ANCHOR_LIB.filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length;
|
|
folderMap.set(seg, under);
|
|
});
|
|
const folders = [...folderMap].map(([name, count])=>({name, count}))
|
|
.sort((a,b)=>a.name.localeCompare(b.name));
|
|
return { folders, items };
|
|
},
|
|
createFolder(path, name){
|
|
if(this.list(path).folders.some(f=>f.name===name)) return T("exExists");
|
|
pending.add(join([...path, name]));
|
|
return null;
|
|
},
|
|
renameFolder(path, oldName, newName){
|
|
return this.moveFolder([...path, oldName], path, newName);
|
|
},
|
|
moveFolder(srcPath, targetPath, newName){
|
|
const from = join(srcPath);
|
|
const to = join([...targetPath, newName || srcPath[srcPath.length-1]]);
|
|
if(from === to) return null;
|
|
if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists");
|
|
ANCHOR_LIB.forEach(it=>{
|
|
const j = join(split(it.folder));
|
|
if(j === from || j.startsWith(from + "/")) it.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(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty");
|
|
[...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); });
|
|
return null;
|
|
},
|
|
renameItem(i, name){
|
|
if(!ANCHOR_LIB[i]) return null;
|
|
ANCHOR_LIB[i].name = name;
|
|
changed();
|
|
return null;
|
|
},
|
|
moveItem(i, targetPath){
|
|
const it = ANCHOR_LIB[i];
|
|
if(!it) return null;
|
|
if(targetPath.length) it.folder = join(targetPath); else delete it.folder;
|
|
pending.delete(join(targetPath));
|
|
changed();
|
|
return null;
|
|
},
|
|
deleteItem(i){
|
|
if(!ANCHOR_LIB[i]) return null;
|
|
ANCHOR_LIB.splice(i, 1);
|
|
changed();
|
|
return null;
|
|
},
|
|
openItem(i){
|
|
const it = ANCHOR_LIB[i];
|
|
return it ? onOpen(it) : null;
|
|
},
|
|
};
|
|
}
|
|
/* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */
|
|
function anchorLibrarySharedHooks(onOpen){
|
|
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
|
|
const join = p => p.join("/");
|
|
return {
|
|
exportItem(i){
|
|
const it = ANCHOR_LIB[i];
|
|
return it ? { name: it.name, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } } : null;
|
|
},
|
|
exportFolder(path){
|
|
const p = join(path);
|
|
const out = [];
|
|
ANCHOR_LIB.forEach(it=>{
|
|
const j = join(split(it.folder));
|
|
if(j === p || j.startsWith(p + "/")){
|
|
const rel = j === p ? [] : j.slice(p.length + 1).split("/");
|
|
out.push({ name: it.name, rel, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } });
|
|
}
|
|
});
|
|
return out;
|
|
},
|
|
copyIn(item, destPath){
|
|
const entry = {
|
|
id: "al-"+Math.random().toString(36).slice(2,10),
|
|
name: item.name,
|
|
cells: structuredClone(item.data.cells || {}),
|
|
pics: structuredClone(item.data.pics || {}),
|
|
};
|
|
if(destPath.length) entry.folder = join(destPath);
|
|
ANCHOR_LIB.push(entry);
|
|
scheduleSave("anchors");
|
|
return null;
|
|
},
|
|
openShared(item){
|
|
return onOpen({ name: item.name, cells: item.data.cells || {}, pics: item.data.pics || {} });
|
|
},
|
|
};
|
|
}
|
|
|