/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */
/* =========================================================
Widget framework
==========================================================*/
const board = document.getElementById("board");
/* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */
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("boards"); 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
te instrumenteren */
document.addEventListener("input", e=>{
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
}, true);
document.addEventListener("pointerup", e=>{
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
}, true);
const dock = document.getElementById("dock");
let zTop = 10, widgetCount = 0;
const instances = new Set();
const REGISTRY = [
/* het ankerwidget staat bewust vooraan: het woordweb voor woordenschat-
onderwijs is de kern van de taalkant en verdient de prominentste plek */
{ id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor },
{ id:"letters", cat:"taal", icon:"🔤", w:760, h:640, minW:640, minH:540, mount:mountLetterGame },
{ id:"flits", cat:"taal", icon:"⚡", w:520, h:430, minW:400, minH:330, mount:mountFlits },
{ id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak },
{ id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall },
{ id:"sentence", cat:"taal", icon:"🧩", w:620, h:520, minW:400, minH:360, mount:mountSentence },
{ id:"madd", cat:"rekenen", icon:"➕", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"add",o) },
{ id:"msub", cat:"rekenen", icon:"➖", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"sub",o) },
{ id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"mul",o) },
{ id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"div",o) },
{ id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s,o)=>mountMath(r,s,"pct",o) },
{ 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:580, h:560, minW:380, minH:360, mount:mountMoney },
{ id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes },
{ id:"trees", cat:"world", icon:"🌳", w:620, h:600, minW:360, minH:400, mount:mountTrees },
{ id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds },
{ id:"plants", cat:"world", icon:"🌼", w:620, h:600, minW:360, minH:400, mount:mountPlants },
{ id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography },
{ 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:"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:"light", cat:"class", icon:"🚦", w:480, h:440, minW:320, minH:320, mount:mountLight },
{ 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:"noise", cat:"class", icon:"🎙️", w:420, h:600, minW:320, minH:460, mount:mountNoise },
{ id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings },
{ id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe },
{ id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments },
{ id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget },
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een
telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv.
640px mag daar niet alsnog horizontale overflow afdwingen. */
function widgetSizeBounds(def, left=0, top=0){
const maxW = Math.max(1, board.clientWidth - left - 8);
const maxH = Math.max(1, board.clientHeight - top - 8);
return {
minW: Math.min(def.minW || 240, maxW),
minH: Math.min(def.minH || 180, maxH),
maxW, maxH,
};
}
function clampWidgetSize(w, h, def){
const {minW,minH,maxW,maxH} = widgetSizeBounds(def);
return {
w: Math.max(minW, Math.min(w, maxW)),
h: Math.max(minH, Math.min(h, maxH)),
};
}
/* stabiel widget-instantie-id, zelfde patroon als genBoardId() verderop - nodig
zodat een toewijzing van één enkele widget (zie pupil.js/admin.js) die widget
blijft vinden ook als er widgets vóór/na worden toegevoegd of verplaatst */
function genWidgetId(){ return "w" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); }
function curriculumGoalsForWidget(widgetId){
const matches=[];
CURRICULUM_PO_2026.areas.forEach(area=>area.goals.forEach(goal=>{
if(goal.widgets.includes(widgetId))matches.push({area,goal});
}));
return matches;
}
let widgetCurriculumWrap=null;
function closeWidgetCurriculumInfo(){
if(widgetCurriculumWrap)widgetCurriculumWrap.classList.remove("open");
}
function openWidgetCurriculumInfo(def){
if(!currentUser||!can("curriculum.view"))return;
if(!widgetCurriculumWrap){
widgetCurriculumWrap=document.createElement("div");
widgetCurriculumWrap.className="modalwrap";
widgetCurriculumWrap.id="widgetCurriculumWrap";
widgetCurriculumWrap.setAttribute("role","dialog");
widgetCurriculumWrap.setAttribute("aria-modal","true");
widgetCurriculumWrap.addEventListener("click",event=>{
if(event.target===widgetCurriculumWrap)closeWidgetCurriculumInfo();
});
document.body.appendChild(widgetCurriculumWrap);
}
const panel=document.createElement("div");
panel.className="modal widget-curriculum-modal";
const head=document.createElement("div");
head.className="widget-curriculum-head";
const title=document.createElement("h2");
title.id="widgetCurriculumTitle";
title.textContent=def.icon+" "+T("wg_"+def.id);
const close=document.createElement("button");
close.type="button";close.className="wclose";close.textContent="✕";
close.title=T("close");close.setAttribute("aria-label",T("close"));
close.addEventListener("click",closeWidgetCurriculumInfo);
head.append(title,close);panel.appendChild(head);
panel.appendChild(document.createTextNode(T("widgetCurriculumIntro")));
const goals=curriculumGoalsForWidget(def.id);
if(goals.length){
const list=document.createElement("div");
list.className="widget-curriculum-goals";
goals.forEach(({area,goal})=>{
const link=document.createElement("a");
link.className="widget-curriculum-goal";
link.href="#wiki-goal-"+goal.id;
const label=document.createElement("strong");
label.textContent=area.icon+" "+T("wikiGoal")+" "+goal.id+" · "+area.title;
const summary=document.createElement("span");
summary.textContent=goal.summary;
const status=document.createElement("small");
status.textContent=T(goal.coverage==="partial"?"wikiCoveragePartial":goal.coverage==="support"?"wikiCoverageSupport":"wikiCoverageOpen")+" · "+T("widgetCurriculumWiki");
link.append(label,summary,status);
link.addEventListener("click",event=>{
event.preventDefault();closeWidgetCurriculumInfo();
openSettings("curriculum",{area:area.id,goal:goal.id});
});
list.appendChild(link);
});
panel.appendChild(list);
}else{
const empty=document.createElement("p");
empty.className="widget-curriculum-empty";
empty.textContent=T("widgetCurriculumNone");
panel.appendChild(empty);
}
const careful=document.createElement("p");
careful.className="widget-curriculum-careful";
careful.textContent=T("widgetCurriculumCareful");
panel.appendChild(careful);
const all=document.createElement("a");
all.className="tbtn widget-curriculum-all";
all.href="#curriculum";
all.textContent=T("widgetCurriculumAll");
all.addEventListener("click",event=>{
event.preventDefault();closeWidgetCurriculumInfo();openSettings("curriculum");
});
panel.appendChild(all);
widgetCurriculumWrap.setAttribute("aria-labelledby",title.id);
widgetCurriculumWrap.replaceChildren(panel);
widgetCurriculumWrap.classList.add("open");
close.focus();
}
document.addEventListener("userchange",()=>{
if(!currentUser||!can("curriculum.view"))closeWidgetCurriculumInfo();
});
function makeWidget(def, saved){
const win = document.createElement("div");
win.className = "widget";
win.dataset.widget = def.id;
const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def);
const bounds = widgetSizeBounds(def);
win.style.width = size.w+"px";
win.style.height = size.h+"px";
win.style.minWidth = bounds.minW+"px";
win.style.minHeight = bounds.minH+"px";
const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240;
const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160;
win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px";
win.style.top = Math.max(0, Math.min(cy, board.clientHeight - size.h - 8)) + "px";
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 infoBtn = document.createElement("button");
infoBtn.className="wmin winfo";infoBtn.type="button";infoBtn.textContent="ⓘ";infoBtn.hidden=true;
infoBtn.addEventListener("click",event=>{event.stopPropagation();openWidgetCurriculumInfo(def);});
head.appendChild(infoBtn);
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);
const minBtn = document.createElement("button");
minBtn.className = "wmin"; minBtn.textContent = "–";
head.appendChild(minBtn);
const maxBtn = document.createElement("button");
maxBtn.className = "wmin"; maxBtn.textContent = "▢";
head.appendChild(maxBtn);
const close = document.createElement("button");
close.className = "wclose"; close.textContent = "✕";
head.appendChild(close);
const body = document.createElement("div");
body.className = "widget-body";
win.appendChild(head); win.appendChild(body);
/* eigen resize-greep (touch-vriendelijk, vervangt de native browser-grip) */
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();
const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY;
const mv = ev=>{
const {minW,minH,maxW,maxH} = widgetSizeBounds(def, win.offsetLeft, win.offsetTop);
let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf;
nw = Math.max(minW, Math.min(nw, maxW));
nh = Math.max(minH, Math.min(nh, maxH));
win.style.width = nw+"px"; win.style.height = nh+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
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){
if(m && !pill){
win.dataset.prevW = win.offsetWidth;
win.dataset.prevH = win.offsetHeight;
win.dataset.min = "1";
win.style.display = "none";
pill = document.createElement("button");
pill.className = "dock-item";
pill.innerHTML = `${def.icon}✕`;
pill.querySelector(".dl").textContent = T("wg_"+def.id);
pill.title = T("wMax");
/* ook een geminimaliseerde widget is direct te sluiten, zonder hem
eerst terug op het bord te hoeven zetten */
const x = pill.querySelector(".dock-close");
x.title = T("close");
x.addEventListener("click", e=>{ e.stopPropagation(); removeWidget(); });
pill.addEventListener("click", ()=>setMin(false));
dock.appendChild(pill);
}else if(!m && pill){
pill.remove(); pill = null;
win.dataset.min = "";
win.style.display = "flex";
win.style.zIndex = ++zTop;
fitWidgetsToBoard();
}
}
minBtn.addEventListener("click", ()=>{ setMin(true); markBoardChange(); });
/* per-widget content zoom for low-vision pupils */
let wz = (saved && saved.wz) || 1;
function applyWZ(){
body.style.zoom = wz;
win.dataset.wz = wz;
zBtn.textContent = wz===1 ? "A+" : Math.round(wz*100)+"%";
zBtn.classList.toggle("on", wz!==1);
zBtn.title = T("wZoom");
}
zBtn.addEventListener("click", ()=>{
wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1;
applyWZ();
markBoardChange();
});
applyWZ();
function setMax(m){
if(m && win.dataset.max!=="1"){
win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop;
win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight;
win.dataset.max = "1";
win.style.left = "0px"; win.style.top = "0px";
win.style.width = board.clientWidth+"px";
win.style.height = board.clientHeight+"px";
win.style.zIndex = ++zTop;
maxBtn.textContent = "❐";
setHudMin(true); /* keep the tools bar out of the way */
}else if(!m && win.dataset.max==="1"){
win.dataset.max = "";
win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px";
win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px";
maxBtn.textContent = "▢";
}
if(!m) fitWidgetsToBoard();
maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull");
}
maxBtn.addEventListener("click", ()=>{ setMax(win.dataset.max!=="1"); markBoardChange(); });
head.addEventListener("dblclick", e=>{
if(e.target.closest("button")) return;
setMin(true);
});
const setTitle = ()=>{
head.querySelector(".wtitle").textContent = T("wg_"+def.id);
minBtn.title = T("wMin");
maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull");
zBtn.title = T("wZoom");
infoBtn.title = T("widgetCurriculumInfo");
infoBtn.setAttribute("aria-label",infoBtn.title);
if(pill){
pill.querySelector(".dl").textContent = T("wg_"+def.id);
pill.title = T("wMax");
pill.querySelector(".dock-close").title = T("close");
}
};
const setCurriculumInfoVisibility=()=>{infoBtn.hidden=!(currentUser&&can("curriculum.view"));};
setTitle();setCurriculumInfoVisibility();
document.addEventListener("langchange", setTitle);
document.addEventListener("userchange",setCurriculumInfoVisibility);
win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; });
head.addEventListener("pointerdown", e=>{
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;
const move = ev=>{
win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px";
win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px";
};
const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",move); document.addEventListener("pointerup",up);
});
board.appendChild(win);
/* Op het eigen bord van een thuiskind is de centrale leerjaarinstelling
leidend. Leerkracht en systeemmanager krijgen hieronder een opgeslagen
keuze per widget; overige borden behouden hun bestaande widgetstate. */
const initialState = typeof learningStateForWidget === "function"
? learningStateForWidget(def, saved ? saved.state : null)
: (saved ? saved.state : null);
let boardLearning = typeof boardLearningSelection === "function"
? boardLearningSelection(def, initialState) : null;
let mountedApi = {};
const mountContent = state=>{
body.replaceChildren();
const mountedState = typeof boardLearningMountState === "function"
? boardLearningMountState(def, state, boardLearning) : state;
mountedApi = def.mount(body, mountedState) || {};
if(typeof appendLearningProfileBadge === "function" && mountedState &&
["taal","rekenen"].includes(def.cat)){
appendLearningProfileBadge(body, def.cat, mountedState, def.id);
}
if(boardLearning && typeof appendBoardLearningLevelControls === "function"){
appendBoardLearningLevelControls(body,def,boardLearning,next=>{
const live=mountedApi.getState?mountedApi.getState():{};
boardLearning=next;
mountContent(Object.assign({},live,next,{managedLearningLevel:false}));
markBoardChange();
});
}
};
mountContent(initialState);
/* Het widget-API blijft stabiel terwijl een niveauwissel de inhoud opnieuw
mount. De auteurskeuze wordt hier terug aan de widgetstate toegevoegd;
widgetmodules laten leerlinggebonden velden zelf bewust buiten getState. */
const api = {
getState:()=>{
const state=mountedApi.getState?mountedApi.getState():null;
return boardLearning?Object.assign({},state||{},boardLearning,{managedLearningLevel:false}):state
},
setState:state=>{mountContent(state)}
};
if(saved && saved.min) setMin(true);
if(saved && saved.max) setMax(true);
const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() };
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);
/* gedeeld sluitpad voor de ✕ in de widgetkop én de ✕ op de dock-pil */
function removeWidget(){
markBoardChange();
if(pill){ pill.remove(); pill = null; }
document.removeEventListener("langchange",setTitle);
document.removeEventListener("userchange",setCurriculumInfoVisibility);
instances.delete(inst); win.remove(); updateEmptyHint();
}
close.addEventListener("click", removeWidget);
updateEmptyHint();
markBoardChange();
return inst;
}
/* Ook reeds geopende widgets blijven passen wanneer een telefoon draait, het
browservenster smaller wordt of een zijbalk de bordruimte verandert. */
function fitWidgetsToBoard(){
instances.forEach(inst=>{
const {win,def} = inst;
const bounds = widgetSizeBounds(def);
win.style.minWidth = bounds.minW+"px";
win.style.minHeight = bounds.minH+"px";
if(win.dataset.min==="1") return;
if(win.dataset.max==="1"){
win.style.left = "0px"; win.style.top = "0px";
win.style.width = board.clientWidth+"px";
win.style.height = board.clientHeight+"px";
return;
}
const width = Math.max(bounds.minW, Math.min(win.offsetWidth, bounds.maxW));
const height = Math.max(bounds.minH, Math.min(win.offsetHeight, bounds.maxH));
win.style.width = width+"px"; win.style.height = height+"px";
win.style.left = Math.max(0, Math.min(win.offsetLeft, board.clientWidth-width-8))+"px";
win.style.top = Math.max(0, Math.min(win.offsetTop, board.clientHeight-height-8))+"px";
});
}
new ResizeObserver(fitWidgetsToBoard).observe(board);
function updateEmptyHint(){
document.body.classList.toggle("board-empty", !board.querySelector(".widget"));
}
function serializeBoard(){
return {
ink: boardInkURL(),
bg: BOARD.bg,
widgets: [...instances].map(inst=>{
const w = inst.win;
const min = w.dataset.min === "1";
const max = w.dataset.max === "1";
return {
id: inst.def.id,
wid: inst.wid,
x: max ? +w.dataset.pmx : w.offsetLeft,
y: max ? +w.dataset.pmy : w.offsetTop,
w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth,
h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight,
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
};
}),
imgs: [...board.querySelectorAll(".bimg")].map(b=>({
src: b.querySelector("img").src,
x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth
}))
};
}
/* tijdens het terugzetten van een bord geen autosaves plannen: het herstellen
zelf is geen wijziging (en zou anders direct na inloggen al een save triggeren) */
let RESTORING = false;
function restoreBoard(b){
RESTORING = true;
[...instances].forEach(inst=>inst.win.remove());
instances.clear();
dock.innerHTML = "";
widgetCount = 0;
(b.widgets||[]).forEach(wd=>{
const def = REGISTRY.find(d=>d.id===wd.id);
if(def) makeWidget(def, wd);
});
setBoardInk(b.ink);
setBoardBg(b.bg || "dots");
board.querySelectorAll(".bimg").forEach(x=>x.remove());
(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) ---------- */
const bInk = document.createElement("canvas");
bInk.id = "boardInk";
board.insertBefore(bInk, board.firstChild);
const bCtx = bInk.getContext("2d");
/* aparte laag voor de markeerstift: dáár tekent hij dekkend, de laag zelf is
doorschijnend (CSS), en pas bij het loslaten wordt de hele haal in één keer
met 32% op de inktlaag gezet. Zo stapelen overlappende lijnstukjes binnen
één haal niet meer tot donkere vlekken op elke overgang. */
const bMark = document.createElement("canvas");
bMark.id = "boardMark";
board.insertBefore(bMark, bInk.nextSibling);
const bMarkCtx = bMark.getContext("2d");
let bDpr = window.devicePixelRatio || 1;
function bResize(){
const w = board.clientWidth, h = board.clientHeight;
if(w<2 || h<2) return;
bDpr = window.devicePixelRatio || 1; /* vers: verhuizen naar ander scherm/zoom */
const keep = document.createElement("canvas");
keep.width = bInk.width; keep.height = bInk.height;
if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0);
bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr);
if(keep.width) bCtx.drawImage(keep,0,0);
bCtx.lineCap = "round"; bCtx.lineJoin = "round";
bMark.width = bInk.width; bMark.height = bInk.height;
bMarkCtx.lineCap = "round"; bMarkCtx.lineJoin = "round";
}
new ResizeObserver(bResize).observe(board);
bResize();
const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 };
const DEFAULT_PEN_COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"];
const RECENT_COLOR_KEY = "teachRecentPenColors";
let recentPenColors = [];
try{ recentPenColors = JSON.parse(localStorage.getItem(RECENT_COLOR_KEY) || "[]").filter(c=>/^#[0-9a-f]{6}$/i.test(c)).slice(0,6); }catch(e){}
const BOARD = { bg:"blank" };
const penbar = document.getElementById("penbar");
function closePenPickers(except){
penbar.querySelectorAll(".pen-picker.open").forEach(p=>{
if(p===except) return;
p.classList.remove("open");
p.querySelector(".pen-current").setAttribute("aria-expanded","false");
});
}
penbar.querySelectorAll(".pen-picker").forEach(p=>{
p.querySelector(".pen-current").addEventListener("click", e=>{
e.stopPropagation();
const open = !p.classList.contains("open");
closePenPickers();
p.classList.toggle("open", open);
p.querySelector(".pen-current").setAttribute("aria-expanded", String(open));
});
});
document.addEventListener("pointerdown", e=>{ if(!e.target.closest(".pen-picker")) closePenPickers(); });
function makeColorButton(c, host){
const b = document.createElement("button");
b.className = "wp-color"; b.dataset.c = c; b.type = "button";
const dot = document.createElement("i"); dot.style.background = c;
b.appendChild(dot);
b.addEventListener("click", ()=>selectPenColor(c));
host.appendChild(b);
}
function renderRecentPenColors(){
const host = penbar.querySelector(".pen-recent");
host.innerHTML = "";
recentPenColors.forEach(c=>makeColorButton(c, host));
penbar.querySelector(".pen-recent-label").hidden = recentPenColors.length===0;
host.hidden = recentPenColors.length===0;
}
function selectPenColor(c){
PEN.color = c.toLowerCase();
if(PEN.tool==="erase") PEN.tool = "pen";
recentPenColors = [PEN.color, ...recentPenColors.filter(x=>x!==PEN.color)].slice(0,6);
try{ localStorage.setItem(RECENT_COLOR_KEY, JSON.stringify(recentPenColors)); }catch(e){}
renderRecentPenColors(); closePenPickers(); refreshPenbar();
}
function setPen(on){
document.body.classList.toggle("pen", on);
document.body.classList.toggle("pen-text", on && PEN.tool==="text");
refreshPenbar();
}
function setBoardBg(bg){
const changed = BOARD.bg !== bg;
BOARD.bg = bg;
board.className = (bg && bg!=="blank") ? "bg-"+bg : "";
refreshPenbar();
if(changed) markBoardChange();
}
const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
PEN_TOOLS.forEach(([id,icon])=>{
const b = document.createElement("button");
b.dataset.tool = id; b.textContent = icon;
b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); closePenPickers(); refreshPenbar(); });
penbar.querySelector(".pen-tools").appendChild(b);
});
DEFAULT_PEN_COLORS.forEach(c=>makeColorButton(c, penbar.querySelector(".pen-colors")));
renderRecentPenColors();
[1,2,3,5,8].forEach(s=>{
const b = document.createElement("button");
b.className = "wp-size"; b.dataset.s = s;
const i = document.createElement("i");
i.style.width = i.style.height = (s+4)+"px";
b.appendChild(i);
b.addEventListener("click", ()=>{ PEN.size = s; closePenPickers(); refreshPenbar(); });
penbar.querySelector(".pen-sizes").appendChild(b);
});
const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
PEN_BGS.forEach(([id])=>{
const b = document.createElement("button");
b.className = "wp-bg"; b.dataset.bg = id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ setBoardBg(id); closePenPickers(); });
penbar.querySelector(".pen-bgs").appendChild(b);
});
const PEN_EXTRAS = [["ruler","📏","toolRuler"],["geo","📐","toolGeo"],["lens","🔍","toolLens"],["light","🚦","wg_light"]];
PEN_EXTRAS.forEach(([id,icon])=>{
const b = document.createElement("button");
b.dataset.extra = id; b.textContent = icon;
b.addEventListener("click", ()=>{ toggleDTool(id); closePenPickers(); });
penbar.querySelector(".pen-extras").appendChild(b);
});
const customPenColor = penbar.querySelector(".pen-custom input");
customPenColor.addEventListener("input", ()=>selectPenColor(customPenColor.value));
customPenColor.addEventListener("click", e=>e.stopPropagation());
penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height));
/* the ✍-button toggles drawing mode on/off (palette itself stays visible) */
penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen")));
/* orientation + collapse of the whole bar */
document.querySelector(".hud-orient").addEventListener("click", ()=>{
document.getElementById("hud").classList.toggle("vert");
});
document.querySelector(".hud-min").addEventListener("click", function(){
setHudMin(!document.getElementById("hud").classList.contains("min"));
});
/* unified draggable bar (pen palette + dock) */
const hud = document.getElementById("hud");
hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{
e.preventDefault();
const zf = VZ();
const r = hud.getBoundingClientRect();
hud.style.transform = "none";
hud.style.bottom = "auto";
hud.style.left = (r.left/zf)+"px";
hud.style.top = (r.top/zf)+"px";
const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf;
const mv = ev=>{
hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px";
hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
function setHudMin(m){
hud.classList.toggle("min", m);
const b = document.querySelector(".hud-min");
if(b) b.textContent = m ? "+" : "–";
}
function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); }
new MutationObserver(updateHud).observe(dock, {childList:true});
updateHud();
function refreshPenbar(){
const toolDef = PEN_TOOLS.find(x=>x[0]===PEN.tool);
penbar.querySelector('[data-picker="tools"] .pen-current-value').textContent = toolDef[1];
penbar.querySelector('[data-picker="tools"] .pen-current').title = T(toolDef[2]);
const currentColor = penbar.querySelector(".pen-current-color");
currentColor.style.background = PEN.color;
penbar.querySelector('[data-picker="colors"] .pen-current').title = T("penColor");
const currentSize = penbar.querySelector(".pen-current-size");
currentSize.style.width = currentSize.style.height = (PEN.size+4)+"px";
penbar.querySelector('[data-picker="sizes"] .pen-current').title = T("penSize");
const currentBg = penbar.querySelector(".pen-current-bg");
currentBg.className = "pen-current-bg bg-"+BOARD.bg;
/* onbekende (oude/corrupte) bg-waarde mag nooit de hele hydrate laten
crashen - dat logde de gebruiker stilletjes uit bij het verversen */
penbar.querySelector('[data-picker="backgrounds"] .pen-current').title = T((PEN_BGS.find(x=>x[0]===BOARD.bg) || PEN_BGS[0])[1]);
penbar.querySelector('[data-picker="extras"] .pen-current').title = T("penExtras");
penbar.querySelector(".pen-recent-label").textContent = T("recentColors");
penbar.querySelector(".pen-palette-label").textContent = T("paletteColors");
penbar.querySelector(".pen-custom-label").textContent = T("customColor");
customPenColor.value = PEN.color;
penbar.querySelectorAll(".pen-tools button").forEach(b=>{
b.classList.toggle("on", b.dataset.tool===PEN.tool);
b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
});
penbar.querySelectorAll(".wp-color").forEach(b=>
b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase"));
penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size));
penbar.querySelectorAll(".wp-bg").forEach(b=>{
b.classList.toggle("on", b.dataset.bg===BOARD.bg);
b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]);
});
penbar.querySelectorAll(".pen-extras button").forEach(b=>{
b.classList.toggle("on", !!DTOOLS[b.dataset.extra]);
b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]);
});
penbar.querySelector(".pen-clear").title = T("clearAll");
const power = penbar.querySelector(".pen-close");
const penOn = document.body.classList.contains("pen");
power.textContent = "✍";
power.title = T("penMode");
power.classList.toggle("on", penOn);
document.querySelector(".hud-orient").title = T("hudOrient");
document.querySelector(".hud-min").title = T("hudMin");
document.body.classList.toggle("pen-text", penOn && PEN.tool==="text");
document.body.classList.toggle("pen-hand", PEN.tool==="hand");
}
document.addEventListener("langchange", refreshPenbar);
/* ---------- physical tools: ruler, set square, magnifier ---------- */
const DTOOLS = {};
function makeMovable(t){
let ang = 0;
t.addEventListener("pointerdown", e=>{
if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return;
e.preventDefault(); e.stopPropagation();
/* pen actief en neergezet op de randzone van de lat: tekenen gaat vóór
verslepen - zoals je een echte liniaal vasthoudt terwijl je erlangs
tekent. Alleen een greep verder van de rand verplaatst het gereedschap. */
if(!e.target.classList.contains("dtool-rot")
&& document.body.classList.contains("pen")
&& ["pen","vulp","mark"].includes(PEN.tool)
&& (t.classList.contains("dtool-ruler") || t.classList.contains("dtool-geo"))){
const r = bInk.getBoundingClientRect();
const px = (e.clientX - r.left) * (board.clientWidth / r.width);
const py = (e.clientY - r.top) * (board.clientHeight / r.height);
const hit = nearestEdge(px, py);
if(hit && hit.d <= 24){ startInkStroke(e, true); return; }
}
/* vastgezet gereedschap verschuift en draait niet meer (slot-knop) */
if(t._locked) return;
if(e.target.classList.contains("dtool-rot")){
const r = t.getBoundingClientRect();
const cx = r.left+r.width/2, cy = r.top+r.height/2;
const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang;
/* klikt vast op veelvouden van 45° (waterpas/haaks in één beweging)
en toont tijdens het draaien de hoek in graden */
const badge = t.querySelector(".dtool-deg") || t.appendChild(Object.assign(document.createElement("div"), { className:"dtool-deg" }));
badge.style.display = "block";
const mv = ev=>{
let a = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start;
const near = Math.round(a/45)*45;
if(Math.abs(a-near) < 4) a = near;
ang = a;
t._ang = ang; /* voor het kleven van de pen aan de gedraaide rand */
t.style.transform = `rotate(${ang}deg)`;
const shown = Math.round(((ang % 360) + 360) % 360);
badge.textContent = shown + "°";
badge.style.transform = `rotate(${-ang}deg)`; /* leesbaar houden */
badge.classList.toggle("snapped", Math.abs(((ang % 45) + 45) % 45) < 0.01);
};
const up = ()=>{
badge.style.display = "none";
document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up);
};
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
return;
}
const zf = VZ();
const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop;
const mv = ev=>{
t.style.left = (ev.clientX/zf-sx)+"px";
t.style.top = (ev.clientY/zf-sy)+"px";
if(t.lensDraw) t.lensDraw();
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
}
function toggleDTool(kind){
if(DTOOLS[kind]){
if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv);
DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return;
}
const t = document.createElement("div");
t.className = "dtool dtool-"+kind;
if(kind==="ruler"){
/* 50px = 1 cm; logische lengtes in hele centimeters, schaal begint bij 0
met een kleine kantlijn zoals op een echte liniaal */
const RULER_CMS = [5, 10, 15, 20];
t._cm = 10; t._hc = false;
const drawRuler = ()=>{
const cmLen = Math.min(t._cm, Math.max(5, Math.floor((board.clientWidth-60)/50)));
const pad = 18, scale = cmLen*50, L = scale + pad*2, hc = t._hc;
const ink = hc ? "#000" : "#5a4a20";
const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)";
let ticks = "";
for(let i=0; i<=scale; i+=5){
const cm = i%50===0, half = i%25===0;
const len = cm ? 22 : half ? 14 : 8;
ticks += `