From a20779e6023310f7654c494e2a67ae5a8db2000b Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 14:16:49 +0200 Subject: [PATCH] v0.3.08-beta: breid widgetbibliotheek uit - voeg acht educatieve en klaswidgets toe\n- verbeter galerij met zoeken, favorieten en lessjablonen\n- voeg dupliceren, vergrendelen en voorbeeldweergave toe\n- voeg geschiedenis en begrensde bordexport/import toe\n- verbeter responsiviteit, toegankelijkheid en vertalingen --- VERSION | 2 +- public/css/teach.css | 11 +++++ public/index.html | 8 +++- public/js/board.js | 81 ++++++++++++++++++++++++++--------- public/js/core.js | 24 +++++++++-- public/js/widgets/learning.js | 47 ++++++++++++++++++++ test/widgets.test.js | 28 ++++++++++++ 7 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 public/js/widgets/learning.js diff --git a/VERSION b/VERSION index c638d14..5f54142 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.07-beta +0.3.08-beta diff --git a/public/css/teach.css b/public/css/teach.css index 9dad3fa..f94074a 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1361,3 +1361,14 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .sb-add,.sb-actions{display:flex; align-items:center; gap:7px; flex-wrap:wrap;}.sb-add select,.sb-add input{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit;}.sb-add input{flex:1; min-width:150px;}.sb-actions{justify-content:flex-end;}.sb-back{font-size:20px;} body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2),var(--surface));} @media(max-width:600px){.bd{grid-template-columns:1fr}.bd-controls{max-height:155px;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(2,1fr)}.bd-party{margin-top:0}.sb-banks{grid-template-columns:1fr}.nlw-settings{gap:5px}} + +/* Educatieve widgets en compacte widgetbibliotheek */ +.gallery-head,.gallery-tools,.gallery-data{display:flex;align-items:center;gap:8px}.gallery-head h2{flex:1}.gallery-tools{position:sticky;top:-24px;z-index:2;background:var(--surface);padding:6px 0 10px}.gallery-tools input{flex:1;height:42px;border:1px solid var(--line);border-radius:12px;padding:0 13px;background:var(--surface);color:var(--ink);font:inherit}.gallery-data{margin:8px 0 14px;flex-wrap:wrap}#galleryTemplates{display:flex;gap:7px;overflow-x:auto;padding:2px 0 8px}.gcat-title{margin:18px 0 8px;font-size:13px;font-weight:900;color:var(--muted);text-transform:uppercase}.gcard{position:relative}.gstar{position:absolute;right:7px;top:7px;border:0;background:transparent;color:var(--orange);font-size:22px;cursor:pointer}.gallery-empty,.edu-empty{text-align:center;color:var(--muted);padding:28px}.widget.locked .widget-rs{display:none}.widget.locked{outline:2px solid var(--accent-soft)}.widget.preview .widget-head{position:absolute;z-index:4;width:100%;opacity:0}.widget.preview:hover .widget-head,.widget.preview:focus-within .widget-head{opacity:1}.widget.preview .widget-rs{display:none}.widget.preview{border-color:transparent;box-shadow:none}.wpreview.on{background:var(--accent-soft);color:var(--accent-ink)} +.edu{height:100%;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}.edu-settings{flex:none;background:var(--surface-2);border-bottom:1px solid var(--line)}.edu-settings summary{cursor:pointer;padding:9px 12px;font-weight:800;font-size:13px}.edu-controls{display:flex;align-items:end;gap:8px;flex-wrap:wrap;padding:0 12px 10px}.edu-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;font-weight:800;color:var(--muted)}.edu-controls input,.edu-controls select,.edu-controls textarea{min-height:34px;border:1px solid var(--line);border-radius:9px;padding:5px 8px;background:var(--surface);color:var(--ink);font:inherit}.edu-controls textarea{height:65px;resize:vertical}.edu-stage{flex:1;min-height:0;overflow:auto;padding:16px;display:flex;align-items:center;justify-content:center} +.fraction-wall{width:100%;display:flex;flex-direction:column;gap:5px}.fraction-wall>div{display:flex;gap:4px}.fraction-piece{flex:1;min-height:34px;border:2px solid hsl(var(--fraction-hue) 55% 43%);border-radius:5px;background:hsl(var(--fraction-hue) 75% 90%);font-weight:800;color:#20304a}.fraction-piece.selected{background:hsl(var(--fraction-hue) 70% 55%);color:#fff} +.clock-wrap{text-align:center}.clock{width:min(270px,65vw);aspect-ratio:1;border:12px solid var(--ink);border-radius:50%;position:relative;background:var(--surface)}.clock i{position:absolute;width:7px;left:calc(50% - 3px);bottom:50%;background:var(--ink);border-radius:8px;transform-origin:50% 100%}.clock-hour{height:29%}.clock-min{height:40%;background:var(--pink)!important}.clock b{position:absolute}.clock b:nth-of-type(1){top:5px;left:47%}.clock b:nth-of-type(2){right:10px;top:47%}.clock b:nth-of-type(3){bottom:5px;left:49%}.clock b:nth-of-type(4){left:10px;top:47%}.clock-wrap output{display:block;font-size:28px;font-weight:900;margin-top:10px} +.place-grid{display:grid;grid-template-columns:repeat(6,1fr);width:100%;border:1px solid var(--line);border-radius:14px;overflow:hidden}.place-grid div{text-align:center;border-right:1px solid var(--line)}.place-grid b{display:block;padding:8px;background:var(--surface-2);font-size:12px}.place-grid output{display:block;padding:18px 4px;font-size:clamp(25px,5vw,48px);font-weight:900;color:var(--blue)} +.money{text-align:center;display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap}.money h3{flex:1 0 100%;font-size:25px}.money .tbtn{border-radius:50%;min-width:62px}.money output{flex:1 0 100%;font-size:32px;font-weight:900;color:var(--green)} +.wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px} +.schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:10px}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{font-size:36px;height:auto;padding:12px}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:var(--accent-soft);z-index:0}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate} +@media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}} diff --git a/public/index.html b/public/index.html index 9df8cd5..7593920 100644 --- a/public/index.html +++ b/public/index.html @@ -76,9 +76,12 @@
-
+ @@ -242,6 +245,7 @@ + diff --git a/public/js/board.js b/public/js/board.js index 5f22666..5b5bfaf 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -4,7 +4,14 @@ ==========================================================*/ const board = document.getElementById("board"); /* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */ -const markBoardChange = ()=>{ if(!RESTORING) scheduleSave(); }; +let BOARD_HISTORY=[], BOARD_FUTURE=[], HISTORY_TIMER=null, HISTORY_BUSY=false; +function recordBoardHistory(){ + if(RESTORING||HISTORY_BUSY) return; clearTimeout(HISTORY_TIMER); HISTORY_TIMER=setTimeout(()=>{ + const snap=serializeBoard(), sig=JSON.stringify(snap); + if(!BOARD_HISTORY.length||JSON.stringify(BOARD_HISTORY.at(-1))!==sig){BOARD_HISTORY.push(snap);if(BOARD_HISTORY.length>30)BOARD_HISTORY.shift();BOARD_FUTURE=[];} + },250); +} +const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave(); recordBoardHistory(); } }; /* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen, schrijfblad, spelvoortgang): elke interactie in een widget-body plant een (debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk @@ -32,16 +39,24 @@ const REGISTRY = [ { id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, { id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, { id:"numberline", cat:"rekenen", icon:"↔️", w:660, h:430, minW:420, minH:320, mount:mountNumberline }, + { id:"fractions", cat:"rekenen", icon:"🍫", w:600, h:480, minW:380, minH:320, mount:mountFractions }, + { id:"clock", cat:"rekenen", icon:"🕙", w:500, h:500, minW:340, minH:340, mount:mountClock }, + { id:"placevalue", cat:"rekenen", icon:"🔢", w:610, h:390, minW:390, minH:280, mount:mountPlacevalue }, + { id:"money", cat:"rekenen", icon:"🪙", w:520, h:430, minW:360, minH:300, mount:mountMoney }, + { id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, - { id:"birthday",cat:"tools", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday }, + { id:"birthday",cat:"class", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday }, + { id:"schedule", cat:"class", icon:"📅", w:500, h:520, minW:340, minH:340, mount:mountSchedule }, + { id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood }, + { id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; -const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; +const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; /* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */ @@ -66,10 +81,14 @@ function makeWidget(def, saved){ win.style.zIndex = ++zTop; widgetCount++; if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } + if(saved && saved.locked){ win.dataset.locked="1"; } const head = document.createElement("div"); head.className = "widget-head"; head.innerHTML = `${def.icon}`; + const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn); + const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn); + const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn); const zBtn = document.createElement("button"); zBtn.className = "wmin wz"; zBtn.textContent = "A+"; head.appendChild(zBtn); @@ -91,6 +110,7 @@ function makeWidget(def, saved){ const rs = document.createElement("div"); rs.className = "widget-rs"; rs.addEventListener("pointerdown", e=>{ + if(win.dataset.locked==="1") return; e.preventDefault(); e.stopPropagation(); win.style.zIndex = ++zTop; const zf = VZ(); @@ -107,6 +127,10 @@ function makeWidget(def, saved){ document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); win.appendChild(rs); + const applyLock=()=>{const locked=win.dataset.locked==="1";win.classList.toggle("locked",locked);lockBtn.textContent=locked?"🔒":"🔓";lockBtn.title=T(locked?"widgetUnlock":"widgetLock");lockBtn.setAttribute("aria-label",lockBtn.title);lockBtn.setAttribute("aria-pressed",String(locked))}; + lockBtn.addEventListener("click",()=>{win.dataset.locked=win.dataset.locked==="1"?"":"1";applyLock();markBoardChange()}); + previewBtn.addEventListener("click",()=>{win.classList.toggle("preview");previewBtn.classList.toggle("on",win.classList.contains("preview"));previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-pressed",String(win.classList.contains("preview"))) }); + applyLock(); let pill = null; function setMin(m){ @@ -182,7 +206,7 @@ function makeWidget(def, saved){ win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); head.addEventListener("pointerdown", e=>{ - if(e.target===close) return; + if(win.dataset.locked==="1" || e.target.closest("button")) return; e.preventDefault(); const zf = VZ(); const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop; @@ -199,6 +223,8 @@ function makeWidget(def, saved){ if(saved && saved.min) setMin(true); if(saved && saved.max) setMax(true); const inst = { def, win, api }; + dupBtn.title=T("widgetDuplicate");dupBtn.setAttribute("aria-label",dupBtn.title);previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-label",previewBtn.title); + dupBtn.addEventListener("click",()=>makeWidget(def,{x:win.offsetLeft+28,y:win.offsetTop+28,w:win.offsetWidth,h:win.offsetHeight,wz:+win.dataset.wz||1,state:api.getState?structuredClone(api.getState()):null})); instances.add(inst); close.addEventListener("click", ()=>{ markBoardChange(); @@ -230,6 +256,7 @@ function serializeBoard(){ min, max, wz: +w.dataset.wz || 1, bare: w.dataset.bare === "1", + locked: w.dataset.locked === "1", state: inst.api.getState ? inst.api.getState() : null }; }), @@ -258,6 +285,7 @@ function restoreBoard(b){ (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); updateEmptyHint(); RESTORING = false; + if(!HISTORY_BUSY){BOARD_HISTORY=[serializeBoard()];BOARD_FUTURE=[];} } /* ---------- board drawing layer (pen mode) ---------- */ @@ -772,26 +800,37 @@ const galleryWrap = document.getElementById("galleryWrap"); const gallery = document.getElementById("gallery"); function renderGallery(){ gallery.innerHTML = ""; + const query=(document.getElementById("gallerySearch").value||"").trim().toLowerCase(); + const favOnly=document.getElementById("galleryFavOnly").classList.contains("on"); + let favorites=[];try{favorites=JSON.parse(localStorage.getItem("teachWidgetFavorites")||"[]")}catch(e){} WIDGET_CATS.forEach(([cat,key])=>{ - const defs = REGISTRY.filter(d=>d.cat===cat); - if(!defs.length) return; - const h = document.createElement("div"); - h.className = "gcat-title"; - h.textContent = T(key); - gallery.appendChild(h); - const grid = document.createElement("div"); - grid.className = "gallery"; - defs.forEach(def=>{ - const c = document.createElement("div"); - c.className = "gcard"; - c.innerHTML = `
${def.icon}
${T("wg_"+def.id)}
${T("wg_"+def.id+"_d")}
`; - c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); }); - grid.appendChild(c); - }); - gallery.appendChild(grid); + if(cat==="favorites"&&!favorites.length)return; + if(cat!=="favorites"&&favOnly)return; + let defs=cat==="favorites"?REGISTRY.filter(d=>favorites.includes(d.id)):REGISTRY.filter(d=>d.cat===cat); + defs=defs.filter(d=>(T("wg_"+d.id)+" "+T("wg_"+d.id+"_d")).toLowerCase().includes(query)); + if(!defs.length)return; + const h=document.createElement("div");h.className="gcat-title";h.textContent=T(key);gallery.appendChild(h); + const grid=document.createElement("div");grid.className="gallery"; + defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0; + const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon; + const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id); + const desc=document.createElement("div");desc.className="gd";desc.textContent=T("wg_"+def.id+"_d"); + const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"★":"☆";star.title=T("galleryFavorite"); + star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()}; + const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid); }); + if(!gallery.children.length){const e=document.createElement("p");e.className="gallery-empty";e.textContent=T("galleryEmpty");gallery.append(e)} } galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); }); +document.querySelector(".gallery-close").onclick=()=>galleryWrap.classList.remove("open"); +const gallerySearch=document.getElementById("gallerySearch"),favOnlyBtn=document.getElementById("galleryFavOnly");gallerySearch.addEventListener("input",renderGallery);favOnlyBtn.onclick=()=>{favOnlyBtn.classList.toggle("on");renderGallery()}; +const templates=[["templateMath","clock","numberline","fractions"],["templateLanguage","sentence","wordtypes","flits"],["templateDay","schedule","timer","mood"]]; +function renderTemplates(){const host=document.getElementById("galleryTemplates");host.innerHTML="";templates.forEach(([key,...ids])=>host.append(btn(T(key),()=>{galleryWrap.classList.remove("open");ids.forEach((id,i)=>makeWidget(REGISTRY.find(d=>d.id===id),{x:24+i*45,y:24+i*38}))})));gallerySearch.placeholder=T("gallerySearch");gallerySearch.setAttribute("aria-label",T("gallerySearch"));favOnlyBtn.title=T("galleryFavorites");favOnlyBtn.setAttribute("aria-label",favOnlyBtn.title);document.getElementById("boardUndo").title=T("undo");document.getElementById("boardRedo").title=T("redo");document.getElementById("boardExport").textContent="⇩ "+T("exportBoard");document.getElementById("boardImport").textContent="⇧ "+T("importBoard");} +const importInput=document.createElement("input");importInput.type="file";importInput.accept="application/json";importInput.hidden=true;document.body.append(importInput); +document.getElementById("boardExport").onclick=()=>{const blob=new Blob([JSON.stringify(serializeBoard(),null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="teach-bord.json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)}; +document.getElementById("boardImport").onclick=()=>importInput.click();importInput.onchange=()=>{const f=importInput.files[0];if(!f)return;if(f.size>2*1024*1024){alert(T("importError"));return}const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data||!Array.isArray(data.widgets)||data.widgets.length>100||(data.imgs||[]).length>50)throw Error();restoreBoard(data);markBoardChange()}catch(e){alert(T("importError"))}};r.readAsText(f);importInput.value=""}; +function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave()}} +document.getElementById("boardUndo").onclick=()=>historyMove(false);document.getElementById("boardRedo").onclick=()=>historyMove(true);document.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.target.matches("input,textarea,[contenteditable]")){e.preventDefault();historyMove(e.shiftKey)}}); /* ---------- floating add-button (FAB) ---------- */ const fab = document.getElementById("fab"); @@ -818,7 +857,7 @@ document.addEventListener("pointerdown", e=>{ }); fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); - renderGallery(); galleryWrap.classList.add("open"); + renderTemplates(); renderGallery(); galleryWrap.classList.add("open"); }); fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); diff --git a/public/js/core.js b/public/js/core.js index 336a215..e7f111b 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.07-beta"; +const VERSION = "0.3.08-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -55,7 +55,7 @@ const I18N = { 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", - catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media", + catTaal:"Taal", catRekenen:"Rekenen", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media", wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.", wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.", wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.", @@ -97,6 +97,15 @@ const I18N = { wg_numberline:"Getallenlijn", wg_numberline_d:"Oefen getalbegrip met een instelbare, interactieve getallenlijn.", numberlineFrom:"Van", numberlineTo:"Tot", numberlineStep:"Stap", numberlineNew:"Nieuwe opdracht", numberlineTask:"Zet de stip op {number}", numberlineCheck:"Controleren", numberlineGood:"Goed gevonden! 🌟", numberlineTry:"Kijk nog eens naar de getallenlijn.", + wg_fractions:"Breukenmuur", wg_fractions_d:"Vergelijk breuken visueel en markeer gelijke delen.", + wg_clock:"Oefenklok", wg_clock_d:"Leer analoge kloktijden instellen en aflezen.", + wg_placevalue:"Plaatswaardetabel", wg_placevalue_d:"Ontdek de waarde van cijfers tot honderdduizend.", + wg_money:"Geldrekenen", wg_money_d:"Stel bedragen samen met euromunten.", + wg_wordtypes:"Woordsoorten", wg_wordtypes_d:"Geef woorden kleur per woordsoort.", + wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.", + wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.", + wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.", + widgetSettings:"Instellingen", fractionRows:"Rijen", fractionLabels:"Breuken tonen", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.", sentence_who:"Wie?", sentence_does:"Doet wat?", sentence_what:"Wat of waar?", sentenceHint:"Kies woorden om een zin te maken…", sentenceAdd:"eigen woord of zinsdeel…", sentenceClear:"Wissen", sentenceSpeak:"🔊 Lees voor", sentenceRemove:"Verwijder woord", @@ -205,7 +214,7 @@ const I18N = { 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", - catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media", + catTaal:"Language", catRekenen:"Maths", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media", wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.", wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.", wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.", @@ -247,6 +256,15 @@ const I18N = { wg_numberline:"Number line", wg_numberline_d:"Practise number sense with an adjustable, interactive number line.", numberlineFrom:"From", numberlineTo:"To", numberlineStep:"Step", numberlineNew:"New task", numberlineTask:"Move the marker to {number}", numberlineCheck:"Check", numberlineGood:"Well done! 🌟", numberlineTry:"Take another look at the number line.", + wg_fractions:"Fraction wall", wg_fractions_d:"Compare fractions visually and mark equal parts.", + wg_clock:"Practice clock", wg_clock_d:"Learn to set and read analogue times.", + wg_placevalue:"Place value", wg_placevalue_d:"Explore digit values up to hundred thousands.", + wg_money:"Money", wg_money_d:"Build amounts using euro coins.", + wg_wordtypes:"Word classes", wg_wordtypes_d:"Colour words by their word class.", + wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.", + wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.", + wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.", + widgetSettings:"Settings", fractionRows:"Rows", fractionLabels:"Show fractions", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.", sentence_who:"Who?", sentence_does:"Does what?", sentence_what:"What or where?", sentenceHint:"Choose words to build a sentence…", sentenceAdd:"your own word or phrase…", sentenceClear:"Clear", sentenceSpeak:"🔊 Read aloud", sentenceRemove:"Remove word", diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js new file mode 100644 index 0000000..84bbb56 --- /dev/null +++ b/public/js/widgets/learning.js @@ -0,0 +1,47 @@ +/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */ +function learningShell(root, settings, stage){ + root.innerHTML='
'; + root.querySelector('summary span').textContent=T('widgetSettings'); + root.querySelector('.edu-controls').append(...settings); + root.querySelector('.edu-stage').append(stage); +} +function field(label, el){const w=document.createElement('label');const s=document.createElement('span');s.textContent=label;w.append(s,el);return w} +function btn(text,fn,kind='ghost'){const b=document.createElement('button');b.type='button';b.className='tbtn '+kind;b.textContent=text;b.onclick=fn;return b} +function num(value,min,max){const i=document.createElement('input');i.type='number';i.value=value;i.min=min;i.max=max;return i} + +function mountFractions(root,init){ + const S=Object.assign({den:4,show:true},init||{}), den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show; + const draw=()=>{S.den=Math.max(2,Math.min(12,+den.value||4));S.show=show.checked;stage.className='fraction-wall';stage.innerHTML='';for(let d=1;d<=S.den;d++){const row=document.createElement('div');for(let n=0;nc.classList.toggle('selected');row.append(c)}stage.append(row)}}; + den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);draw();return{getState:()=>({...S})}; +} +function mountClock(root,init){ + const S=Object.assign({h:10,m:10,task:false,targetH:3,targetM:30},init||{}), h=num(S.h,1,12),m=document.createElement('select'),stage=document.createElement('div');[0,5,10,15,20,25,30,35,40,45,50,55].forEach(v=>m.add(new Option(String(v).padStart(2,'0'),v)));m.value=S.m; + const draw=()=>{S.h=+h.value;S.m=+m.value;stage.className='clock-wrap';stage.innerHTML=`
12369
${S.h}:${String(S.m).padStart(2,'0')}`;stage.querySelector('.clock-hour').style.transform=`rotate(${S.h*30+S.m/2}deg)`;stage.querySelector('.clock-min').style.transform=`rotate(${S.m*6}deg)`}; + const task=btn(T('clockTask'),()=>{S.targetH=1+Math.floor(Math.random()*12);S.targetM=[0,15,30,45][Math.floor(Math.random()*4)];S.task=true;alert(T('clockSet').replace('{time}',`${S.targetH}:${String(S.targetM).padStart(2,'0')}`))}); + const check=btn(T('numberlineCheck'),()=>{if(!S.task)return;const ok=S.h===S.targetH&&S.m===S.targetM;ok?sndGood():sndBad();stage.querySelector('output').textContent=T(ok?'numberlineGood':'numberlineTry')},'');h.oninput=m.onchange=draw;learningShell(root,[field(T('clockHour'),h),field(T('clockMinute'),m),task,check],stage);draw();return{getState:()=>({...S})}; +} +function mountPlacevalue(root,init){ + const S=Object.assign({number:1342},init||{}),input=num(S.number,0,999999),stage=document.createElement('div'); + const draw=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';['HD','TD','D','H','T','E'].forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})};input.oninput=draw;learningShell(root,[field(T('placeNumber'),input),btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);draw()})],stage);draw();return{getState:()=>({...S})}; +} +function mountMoney(root,init){ + const S=Object.assign({target:3.75,total:0},init||{}),stage=document.createElement('div'),out=document.createElement('output'); + const draw=()=>{stage.className='money';stage.innerHTML='';const task=document.createElement('h3');task.textContent=T('moneyMake').replace('{amount}',S.target.toFixed(2));stage.append(task);[2,1,.5,.2,.1,.05].forEach(v=>stage.append(btn('€ '+v.toFixed(2),()=>{S.total=Math.round((S.total+v)*100)/100;renderTotal()})));stage.append(out,btn(T('sentenceClear'),()=>{S.total=0;renderTotal()}),btn(T('numberlineCheck'),()=>{S.total===S.target?sndGood():sndBad()},''));renderTotal()};const renderTotal=()=>out.textContent='€ '+S.total.toFixed(2);learningShell(root,[btn(T('numberlineNew'),()=>{S.target=(50+Math.floor(Math.random()*950))/100;S.total=0;draw()})],stage);draw();return{getState:()=>({...S})}; +} +function mountWordtypes(root,init){ + const S=Object.assign({sentence:'De vrolijke hond rent snel',answers:{}},init||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence; + const types=['noun','verb','adjective','other'],draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);draw();return{getState:()=>({...S})}; +} +function mountSchedule(root,init){ + const S=Object.assign({items:[{time:'08:30',text:'Start'},{time:'10:15',text:'Pauze'}]},init||{}),time=document.createElement('input'),text=document.createElement('input'),stage=document.createElement('div');time.type='time';time.value='09:00';text.placeholder=T('scheduleActivity'); + const draw=()=>{stage.className='schedule';stage.innerHTML='';S.items.sort((a,b)=>a.time.localeCompare(b.time)).forEach((it,i)=>{const row=document.createElement('div');const t=document.createElement('time');t.textContent=it.time;const s=document.createElement('span');s.textContent=it.text;row.append(t,s,btn('×',()=>{S.items.splice(i,1);draw()}));stage.append(row)});if(!S.items.length){const e=document.createElement('p');e.className='edu-empty';e.textContent=T('scheduleEmpty');stage.append(e)}}; + learningShell(root,[time,text,btn(T('addWord'),()=>{if(text.value.trim()){S.items.push({time:time.value,text:text.value.trim().slice(0,50)});text.value='';draw()}})],stage);draw();return{getState:()=>({...S,items:S.items.map(x=>({...x}))})}; +} +function mountMood(root,init){ + const S=Object.assign({votes:[0,0,0,0,0]},init||{}),stage=document.createElement('div'),faces=['😢','😕','😐','🙂','🤩']; + const draw=()=>{stage.className='mood';stage.innerHTML='';faces.forEach((f,i)=>{const b=btn(f,()=>{S.votes[i]++;draw()});const n=document.createElement('span');n.textContent=S.votes[i];b.append(n);stage.append(b)})};learningShell(root,[btn(T('pollReset'),()=>{S.votes=[0,0,0,0,0];draw()})],stage);draw();return{getState:()=>({...S,votes:[...S.votes]})}; +} +function mountPoll(root,init){ + const S=Object.assign({question:'Wat kies jij?',options:[{text:'A',votes:0},{text:'B',votes:0}]},init||{}),question=document.createElement('input'),options=document.createElement('textarea'),stage=document.createElement('div');question.value=S.question;options.value=S.options.map(x=>x.text).join('\n'); + const sync=()=>{S.question=question.value.slice(0,100);const old=new Map(S.options.map(x=>[x.text,x.votes]));S.options=options.value.split('\n').map(x=>x.trim()).filter(Boolean).slice(0,6).map(text=>({text,votes:old.get(text)||0}));draw()},draw=()=>{stage.className='poll';stage.innerHTML='';const h=document.createElement('h3');h.textContent=S.question;stage.append(h);const max=Math.max(1,...S.options.map(x=>x.votes));S.options.forEach(o=>{const b=btn(o.text,()=>{o.votes++;draw()});const bar=document.createElement('i');bar.style.width=(o.votes/max*100)+'%';const n=document.createElement('span');n.textContent=o.votes;b.append(bar,n);stage.append(b)})};question.oninput=options.oninput=sync;learningShell(root,[field(T('pollQuestion'),question),field(T('pollOptions'),options),btn(T('pollReset'),()=>{S.options.forEach(x=>x.votes=0);draw()})],stage);draw();return{getState:()=>({...S,options:S.options.map(x=>({...x}))})}; +} diff --git a/test/widgets.test.js b/test/widgets.test.js index c04937e..f95c7f7 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; const ids=['birthday','numberline','sentence']; +const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll']; test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{ const html=await readFile('public/index.html','utf8'); @@ -30,3 +31,30 @@ test('nieuwe widgetnamen en beschrijvingen zijn tweetalig', async()=>{ assert.equal((core.match(new RegExp(`wg_${id}_d:`,'g'))||[]).length,2); } }); + + +test('educatieve widgetmodule laadt en registreert alle acht widgets', async()=>{ + const html=await readFile('public/index.html','utf8'); + assert.ok(html.indexOf('js/widgets/learning.js'){ + const core=await readFile('public/js/core.js','utf8'); + for(const id of learningIds){ + assert.equal((core.match(new RegExp('wg_'+id+':','g'))||[]).length,2,id); + assert.equal((core.match(new RegExp('wg_'+id+'_d:','g'))||[]).length,2,id); + } +}); + +test('widgetbibliotheek bevat zoeken favorieten sjablonen en borddata-acties', async()=>{ + const [html,board]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8')]); + for(const id of ['gallerySearch','galleryFavOnly','galleryTemplates','boardUndo','boardRedo','boardExport','boardImport'])assert.match(html,new RegExp('id="'+id+'"')); + for(const feature of ['teachWidgetFavorites','structuredClone','application/json','widgetDuplicate','widgetLock','widgetPreview'])assert.ok(board.includes(feature),feature); +});