/* 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 = `
`; 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 */ pic.classList.add("an-pic-empty"); if(!readonly){ 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"; 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"), 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} = pd; pd = null; if(moved){ render(); return; } if(target && !readonly) 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()); } /* ---- ankerbibliotheek: diagram los van het bord bewaren en hergebruiken. Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via dezelfde Windows-stijl mappenverkenner als de borden en thema's. */ function anchorLibAdapter(){ 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; }, /* dubbelklik: bewaard diagram in dít widget laden */ openItem(i){ const it = ANCHOR_LIB[i]; if(!it) return; S.cells = structuredClone(it.cells || {}); S.pics = structuredClone(it.pics || {}); S.pan = {x:0, y:0}; syncSource(); render(); persistWords(); /* programmatisch geladen: de bord-autosave-listeners zien dit niet */ markBoardChange(); }, }; } 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); }); /* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */ const anchorSharedHooks = ()=>{ 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){ S.cells = structuredClone(item.data.cells || {}); S.pics = structuredClone(item.data.pics || {}); S.pan = {x:0, y:0}; syncSource(); render(); persistWords(); markBoardChange(); return null; }, }; }; libBtn.addEventListener("click", ()=>openExplorer(withSharedBranches("anchor", anchorLibAdapter(), anchorSharedHooks()))); } 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 }) }; }