Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
7 changed files with 174 additions and 27 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.07-beta
|
||||
0.3.08-beta
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -76,9 +76,12 @@
|
|||
<div id="dock"></div>
|
||||
</div>
|
||||
|
||||
<div class="modalwrap" id="galleryWrap">
|
||||
<div class="modalwrap" id="galleryWrap" role="dialog" aria-modal="true" aria-labelledby="galleryTitle">
|
||||
<div class="modal">
|
||||
<h2 data-i18n="chooseWidget"></h2>
|
||||
<div class="gallery-head"><h2 id="galleryTitle" data-i18n="chooseWidget"></h2><button class="wclose gallery-close" type="button">✕</button></div>
|
||||
<div class="gallery-tools"><input id="gallerySearch" type="search" autocomplete="off"><button id="galleryFavOnly" class="tbtn ghost" type="button">☆</button></div>
|
||||
<div id="galleryTemplates"></div>
|
||||
<div class="gallery-data"><button id="boardUndo" class="tbtn ghost" type="button">↶</button><button id="boardRedo" class="tbtn ghost" type="button">↷</button><button id="boardExport" class="tbtn ghost" type="button"></button><button id="boardImport" class="tbtn ghost" type="button"></button></div>
|
||||
<div id="gallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -242,6 +245,7 @@
|
|||
<script src="js/widgets/birthday.js"></script>
|
||||
<script src="js/widgets/numberline.js"></script>
|
||||
<script src="js/widgets/sentence.js"></script>
|
||||
<script src="js/widgets/learning.js"></script>
|
||||
<script src="js/board.js"></script>
|
||||
<script src="js/pupil.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
|
|
|
|||
|
|
@ -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 = `<span class="wicon">${def.icon}</span><span class="wtitle"></span>`;
|
||||
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(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.innerHTML = `<div class="gi">${def.icon}</div><div class="gn">${T("wg_"+def.id)}</div><div class="gd">${T("wg_"+def.id+"_d")}</div>`;
|
||||
c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); });
|
||||
grid.appendChild(c);
|
||||
});
|
||||
gallery.appendChild(grid);
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
47
public/js/widgets/learning.js
Normal file
47
public/js/widgets/learning.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */
|
||||
function learningShell(root, settings, stage){
|
||||
root.innerHTML='<div class="edu"><details class="edu-settings"><summary>⚙ <span></span></summary><div class="edu-controls"></div></details><div class="edu-stage"></div></div>';
|
||||
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;n<d;n++){const c=document.createElement('button');c.type='button';c.style.setProperty('--fraction-hue',d*31);c.textContent=S.show?`1/${d}`:'';c.className='fraction-piece';c.onclick=()=>c.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=`<div class="clock"><i class="clock-hour"></i><i class="clock-min"></i><b>12</b><b>3</b><b>6</b><b>9</b></div><output>${S.h}:${String(S.m).padStart(2,'0')}</output>`;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}))})};
|
||||
}
|
||||
|
|
@ -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')<html.indexOf('js/board.js'));
|
||||
const [board,source]=await Promise.all([readFile('public/js/board.js','utf8'),readFile('public/js/widgets/learning.js','utf8')]);
|
||||
for(const id of learningIds){
|
||||
assert.match(board,new RegExp('id:"'+id+'"'));
|
||||
const mount='mount'+id[0].toUpperCase()+id.slice(1);
|
||||
assert.match(source,new RegExp('function '+mount+'\\('));
|
||||
}
|
||||
assert.equal((source.match(/getState:/g)||[]).length,learningIds.length);
|
||||
});
|
||||
|
||||
test('nieuwe widgets zijn tweetalig beschreven', async()=>{
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue