/* 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:"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 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 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===close || e.target===minBtn || e.target===maxBtn) 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");
if(pill){
pill.querySelector(".dl").textContent = T("wg_"+def.id);
pill.title = T("wMax");
pill.querySelector(".dock-close").title = T("close");
}
};
setTitle();
document.addEventListener("langchange", setTitle);
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);
const api = def.mount(body, saved ? saved.state : null) || {};
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; }
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 += ``;
if(cm) ticks += `${i/50}`;
}
t.style.width = L+"px";
t.querySelector(".dtool-svg").innerHTML = ``;
/* tekenranden voor het kleven van de pen (bord-lokale coördinaten) */
t._edges = [[[0,0],[L,0]], [[0,76],[L,76]]];
const lenBtn = t.querySelector(".do-len");
lenBtn.textContent = cmLen + " cm";
lenBtn.title = T("dtLen");
};
t.innerHTML = `
`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._cm = RULER_CMS[(RULER_CMS.indexOf(t._cm)+1) % RULER_CMS.length]; drawRuler();
});
const hcBtn = t.querySelector(".do-hc");
hcBtn.title = T("dtContrast");
hcBtn.addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); });
t.style.height = "76px";
drawRuler();
}else if(kind==="geo"){
/* geodriehoek zoals de echte: cm-schaal op de hypotenusa met 0 in het
midden, gradenbogen in twee richtingen, en twee niveaus:
basis - grote cijfers, alleen de hoofdhoeken (30/45/60/90/…) en
hele centimeters, voor jonge leerlingen;
volledig - mm-streepjes, graden per 2° met dubbele schaal (links- én
rechtsom, zoals op een echte geodriehoek) en evenwijdige
hulplijnen per cm om parallelle lijnen te tekenen. */
const GEO_CMS = [6, 8, 12];
t._cm = 8; t._hc = false; t._full = false;
const drawGeo = ()=>{
const maxCm = Math.max(6, Math.floor((board.clientWidth-40)/50));
const cmLen = Math.min(t._cm, maxCm);
const W = cmLen*50 + 28, H = W/2 + 22, hc = t._hc, full = t._full;
const ink = hc ? "#000" : "#1e4f74";
const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.55)";
const halo = `stroke="${hc?"#ffd400":"#ffffff"}" stroke-width="3.5" paint-order="stroke" stroke-linejoin="round"`;
const ax = W/2, ay = 16, by = H-18; /* apex + tekenrand */
const half = W/2 - 14;
/* cm-schaal op de tekenrand, 0 in het midden; volledig ook mm */
let cmTicks = `0`;
const stepPx = full ? 5 : 25;
for(let i=stepPx; i<=half; i+=stepPx){
const cm = i%50===0, halfCm = i%25===0;
const len = cm ? 13 : halfCm ? 9 : 5;
[ax-i, ax+i].forEach(x=>{
cmTicks += ``;
});
if(cm && i<=half-14){
[ax-i, ax+i].forEach(x=>{
cmTicks += `${i/50}`;
});
}
}
/* gradenboog: basis = hoofdhoeken groot; volledig = per 2° met dubbele
schaal (buiten linksom, binnen rechtsom) zoals op een echte geodriehoek */
let degs = "";
const R = half*0.62;
const degStep = full ? 2 : 10;
for(let d=degStep; d<180; d+=degStep){
const major = full ? d%10===0 : d%30===0 || d===45 || d===135;
const rad = d*Math.PI/180;
const x1 = ax - Math.cos(rad)*(R-(major?10:5)), y1 = by - Math.sin(rad)*(R-(major?10:5));
const x2 = ax - Math.cos(rad)*R, y2 = by - Math.sin(rad)*R;
degs += ``;
}
/* 10° en 170° liggen vrijwel op de baseline en zouden de cm-cijfers
overlappen - net als op een echte geodriehoek beginnen de labels
daar pas een stap hoger (de streepjes staan er wel) */
const labels = full
? Array.from({length:15}, (_,i)=>(i+2)*10)
: [30,45,60,90,120,135,150];
labels.forEach(d=>{
const rad = d*Math.PI/180;
if(full){
const xo = ax - Math.cos(rad)*(R-20), yo = by - Math.sin(rad)*(R-20);
const xi = ax - Math.cos(rad)*(R-36), yi = by - Math.sin(rad)*(R-36);
degs += `${d}`;
degs += `${180-d}`;
}else{
const xt = ax - Math.cos(rad)*(R-24), yt = by - Math.sin(rad)*(R-24);
degs += `${d}°`;
}
});
/* volledig: evenwijdige hulplijnen per cm, parallel aan de tekenrand */
let parallels = "";
if(full){
for(let yy=by-50; yy>ay+24; yy-=50){
const hw = half * (yy-ay)/(by-ay) - 10;
if(hw < 30) break;
parallels += ``;
}
}
t.style.width = W+"px"; t.style.height = H+"px";
t.querySelector(".dtool-svg").innerHTML = ``;
/* tekenranden voor het kleven van de pen (bord-lokale coördinaten) */
t._edges = [[[14,by],[W-14,by]], [[14,by],[ax,ay]], [[W-14,by],[ax,ay]]];
const lenBtn = t.querySelector(".do-len");
lenBtn.textContent = cmLen + " cm";
lenBtn.title = T("dtLen");
const modeBtn = t.querySelector(".do-mode");
modeBtn.textContent = T(full ? "dtModeFull" : "dtModeBasic");
modeBtn.title = T("dtMode");
};
t.innerHTML = `
`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._cm = GEO_CMS[(GEO_CMS.indexOf(t._cm)+1) % GEO_CMS.length]; drawGeo();
});
t.querySelector(".do-mode").addEventListener("click", ()=>{ t._full = !t._full; drawGeo(); });
const geoHc = t.querySelector(".do-hc");
geoHc.title = T("dtContrast");
geoHc.addEventListener("click", ()=>{ t._hc = !t._hc; drawGeo(); });
drawGeo();
}else if(kind==="light"){ /* compact traffic light directly on the board */
let on = "green";
t.innerHTML = `
`;
const apply = ()=>t.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on));
t.querySelectorAll(".tl-lamp").forEach(l=>{
l.addEventListener("pointerdown", e=>e.stopPropagation());
l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); });
});
apply();
t.style.left = (board.clientWidth-140)+"px"; t.style.top = "20px";
}else{ /* lens: magnifies background, pictures and ink (2x); resizable, round or square */
const Z = 2, LMIN = 120, LMAX = 340;
t._d = 184; t._sq = false;
const c = document.createElement("canvas");
t.appendChild(c);
t.lensDraw = ()=>{
const d = t._d, S = Z*2; /* canvas is 2x for sharpness */
const ctx2 = c.getContext("2d");
const cx = t.offsetLeft + d/2 + 2, cy = t.offsetTop + d/2 + 2; /* board css px */
const sw = d/Z;
const sx = cx - sw/2, sy = cy - sw/2;
ctx2.fillStyle = "#eef3f8";
ctx2.fillRect(0, 0, d*2, d*2);
if(BOARD.bg === "dots"){
ctx2.fillStyle = "#c9d6e5";
const g = 26;
for(let gx = Math.floor(sx/g)*g; gx < sx+sw; gx += g){
for(let gy = Math.floor(sy/g)*g; gy < sy+sw; gy += g){
ctx2.beginPath();
ctx2.arc((gx-sx)*S, (gy-sy)*S, 1.4*S, 0, 7);
ctx2.fill();
}
}
}
board.querySelectorAll(".bimg").forEach(b=>{
const im = b.querySelector("img");
if(!im.complete || !im.naturalWidth) return;
try{
ctx2.drawImage(im, (b.offsetLeft-sx)*S, (b.offsetTop-sy)*S, b.offsetWidth*S, b.offsetHeight*S);
}catch(e){}
});
try{
ctx2.drawImage(bInk, sx*bDpr, sy*bDpr, sw*bDpr, sw*bDpr, 0, 0, d*2, d*2);
}catch(e){}
};
const lensSetup = ()=>{
const d = t._d;
c.width = d*2; c.height = d*2;
c.style.width = d+"px"; c.style.height = d+"px";
c.style.borderRadius = t._sq ? "16px" : "50%";
t.style.width = (d+4)+"px"; t.style.height = (d+4)+"px";
t.lensDraw();
};
const opts = document.createElement("div");
opts.className = "dtool-opts";
[["−","lensSmall",()=>{ t._d = Math.max(LMIN, t._d-40); lensSetup(); }],
["+","lensBig", ()=>{ t._d = Math.min(LMAX, t._d+40); lensSetup(); }],
["▢","lensShape",()=>{ t._sq = !t._sq; lensSetup(); }]].forEach(([txt,key,fn])=>{
const b = document.createElement("button");
b.textContent = txt; b.title = T(key);
b.addEventListener("click", fn);
opts.appendChild(b);
});
t.appendChild(opts);
lensSetup();
t._iv = setInterval(()=>t.lensDraw(), 350);
}
const x = document.createElement("button");
x.className = "dtool-x"; x.textContent = "✕";
x.addEventListener("click", ()=>{ if(t._iv) clearInterval(t._iv); t.remove(); delete DTOOLS[kind]; refreshPenbar(); });
t.appendChild(x);
if(kind==="ruler" || kind==="geo"){
const rot = document.createElement("button");
rot.className = "dtool-rot"; rot.textContent = "↻";
t.appendChild(rot);
/* slot: zet het gereedschap vast op zijn plek - niets kan hem meer
verschuiven of draaien, zodat er rustig langs getekend kan worden */
const lockBtn = t.querySelector(".do-lock");
const syncLock = ()=>{
lockBtn.textContent = t._locked ? "🔒" : "🔓";
lockBtn.title = T("dtLock");
lockBtn.setAttribute("aria-pressed", String(!!t._locked));
lockBtn.classList.toggle("on", !!t._locked);
rot.style.display = t._locked ? "none" : "";
};
lockBtn.addEventListener("click", ()=>{ t._locked = !t._locked; syncLock(); });
syncLock();
}
makeMovable(t);
board.appendChild(t);
DTOOLS[kind] = t;
if(t.lensDraw) t.lensDraw();
refreshPenbar();
}
/* board text tool */
function startBoardText(e){
if(board.querySelector(".wp-text-input")) return;
const zf = VZ();
const r = board.getBoundingClientRect();
const x = (e.clientX - r.left)/zf, y = (e.clientY - r.top)/zf;
const fontPx = PEN.size*6 + 12;
const inp = document.createElement("input");
inp.type = "text"; inp.className = "wp-text-input";
inp.style.left = Math.min(x, board.clientWidth-150)+"px";
inp.style.top = Math.min(y, board.clientHeight-40)+"px";
inp.style.color = PEN.color;
inp.style.fontSize = fontPx+"px";
inp.style.zIndex = 4700;
board.appendChild(inp);
setTimeout(()=>inp.focus(), 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v){
bCtx.globalCompositeOperation = "source-over";
bCtx.globalAlpha = 1;
bCtx.fillStyle = PEN.color;
bCtx.font = `700 ${fontPx*bDpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
bCtx.textBaseline = "top";
bCtx.fillText(v, x*bDpr, y*bDpr);
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
}
inp.remove();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; inp.remove(); }
});
inp.addEventListener("blur", commit);
}
let bDraw = false, bx = 0, by = 0, bMidX = null, bMidY = null, bUsedMark = false;
const bPos = ev=>{
const r = bInk.getBoundingClientRect();
/* ratio-based: correct under any display zoom */
return [(ev.clientX-r.left)*(bInk.width/r.width), (ev.clientY-r.top)*(bInk.height/r.height)];
};
/* ---- langs de liniaal/geodriehoek tekenen -----------------------------------
Elke rand van de liniaal en geodriehoek is een "tekenrand". Begint een haal
binnen 16px van zo'n rand, dan wordt die rand voor de hele haal vergrendeld
en wordt elk punt erop geprojecteerd: een kaarsrechte lijn, precies zoals
tekenen tegen een echte lat. Verder dan ~60px van de rand laat de pen weer
los (bewust weggetrokken). Randen draaien mee met het gereedschap. */
let bSnap = null;
function toolEdges(){
const edges = [];
["ruler","geo"].forEach(kind=>{
const t = DTOOLS[kind];
if(!t || !t._edges) return;
const rad = (t._ang||0)*Math.PI/180;
const cosA = Math.cos(rad), sinA = Math.sin(rad);
const cx = t.offsetLeft + t.offsetWidth/2, cy = t.offsetTop + t.offsetHeight/2;
const rot = ([x,y])=>{
const dx = x - t.offsetWidth/2, dy = y - t.offsetHeight/2;
return [cx + dx*cosA - dy*sinA, cy + dx*sinA + dy*cosA];
};
t._edges.forEach(seg=>edges.push([rot(seg[0]), rot(seg[1])]));
});
return edges;
}
function nearestEdge(px, py){
let best = null;
toolEdges().forEach(([a,b])=>{
const ux = b[0]-a[0], uy = b[1]-a[1], len = Math.hypot(ux,uy);
if(len < 1) return;
const along = Math.max(0, Math.min(len, ((px-a[0])*ux + (py-a[1])*uy)/len));
const qx = a[0] + ux/len*along, qy = a[1] + uy/len*along;
const d = Math.hypot(px-qx, py-qy);
if(!best || d < best.d) best = { d, ax:a[0], ay:a[1], ux:ux/len, uy:uy/len, len };
});
return best;
}
function snapPoint(x, y){
if(!bSnap) return [x, y];
const k = bInk.width / board.clientWidth; /* canvas-px per bord-px */
const px = x/k, py = y/k;
const along = Math.max(0, Math.min(bSnap.len, ((px-bSnap.ax)*bSnap.ux + (py-bSnap.ay)*bSnap.uy)));
const qx = bSnap.ax + bSnap.ux*along, qy = bSnap.ay + bSnap.uy*along;
if(Math.hypot(px-qx, py-qy) > 60){ bSnap = null; return [x, y]; }
return [qx*k, qy*k];
}
/* start van een haal; ook aanroepbaar vanaf de liniaal/geodriehoek zelf
(pen op de randzone van de lat = tekenen, niet verslepen) - de pointer
capture op bInk stuurt de vervolg-events vanzelf hierheen.
Naast de lat beslist de RICHTING: binnen 22px van een rand wachten we de
eerste ~7px beweging af; loopt die grofweg evenwijdig aan de rand (≤30°),
dan kleeft de haal - anders blijft hij vrij. Zo grijpt de lat geen vrije
halen en werkt langs de rand tekenen toch betrouwbaar. Op de lat zelf
(fromTool) is de intentie ondubbelzinnig en kleeft de haal direct. */
let bPend = null;
function startInkStroke(e, fromTool = false){
if(PEN.tool==="text"){ startBoardText(e); return; }
bInk.setPointerCapture(e.pointerId);
bDraw = true;
PEN.nib = PEN.size*0.8;
[bx,by] = bPos(e);
bSnap = null; bPend = null;
bMidX = bMidY = null;
if(PEN.tool!=="erase" && PEN.tool!=="hand"){
const k = bInk.width / board.clientWidth;
const hit = nearestEdge(bx/k, by/k);
if(fromTool){
if(hit && hit.d <= 24) bSnap = hit;
}else if(hit && hit.d <= 22){
bPend = { x0:bx, y0:by, hit };
return; /* nog niets tekenen; de eerste beweging beslist (flush hieronder) */
}
}
[bx,by] = snapPoint(bx, by);
bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType);
}
bInk.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
startInkStroke(e);
});
bInk.addEventListener("pointermove", e=>{
if(!bDraw) return;
(e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{
const [rx,ry] = bPos(ev);
if(bPend){
const k = bInk.width / board.clientWidth;
const dx = rx - bPend.x0, dy = ry - bPend.y0;
if(Math.hypot(dx, dy)/k < 7) return; /* nog te weinig beweging om te beslissen */
const move = Math.atan2(dy, dx), edge = Math.atan2(bPend.hit.uy, bPend.hit.ux);
let diff = Math.abs(move - edge) % Math.PI;
diff = Math.min(diff, Math.PI - diff);
if(diff <= Math.PI/6) bSnap = bPend.hit;
const [sx, sy] = snapPoint(bPend.x0, bPend.y0);
bx = sx; by = sy; bMidX = bMidY = null;
bStroke(sx+0.01, sy+0.01, ev.pressure, ev.pointerType);
bPend = null;
}
const [x,y] = snapPoint(rx, ry);
bStroke(x, y, ev.pressure, ev.pointerType);
});
});
const bStop = ()=>{
if(bDraw && bPend){
/* tik zonder beweging vlak naast de lat: gewoon een vrije stip zetten */
bx = bPend.x0; by = bPend.y0; bMidX = bMidY = null;
bStroke(bx+0.01, by+0.01, 0, "mouse");
bPend = null;
}
if(bDraw){
/* markeerstift: de haal staat dekkend op de overlay - nu in één keer
met vaste transparantie op de inktlaag zetten (egale kleur, geen
donkere overgangen) en de overlay leegmaken */
if(bUsedMark && bMark.width){
bCtx.globalCompositeOperation = "source-over";
bCtx.globalAlpha = bOverlayAlpha;
bCtx.drawImage(bMark, 0, 0);
bCtx.globalAlpha = 1;
bMarkCtx.clearRect(0, 0, bMark.width, bMark.height);
}
bUsedMark = false;
markBoardChange();
}
bDraw = false;
bSnap = null;
bCtx.globalAlpha = 1;
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
};
bInk.addEventListener("pointerup", bStop);
bInk.addEventListener("pointercancel", bStop);
/* gereedschappen met transparantie tekenen op de overlay-laag (dekkend) en
worden bij het loslaten in één keer doorgezet - egaal, zonder donkere
overgangen: potlood zacht grafiet-achtig, markeerstift doorschijnend */
const INK_ALPHA = { pen:0.85, mark:0.32 };
let bOverlayAlpha = 1;
function bStroke(x,y,pressure,ptype){
const layerAlpha = INK_ALPHA[PEN.tool];
if(layerAlpha){ bUsedMark = true; bOverlayAlpha = layerAlpha; bMark.style.opacity = layerAlpha; }
const ctx = layerAlpha ? bMarkCtx : bCtx;
ctx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over";
ctx.globalAlpha = 1;
ctx.strokeStyle = PEN.color;
let w = PEN.size;
if(PEN.tool==="erase") w = PEN.size*6;
else if(PEN.tool==="mark") w = PEN.size*3.5;
else if(PEN.tool==="vulp"){
/* kalligrafie met een 45°-penpunt: dik in de neerhaal (↘/↖ dwars op de
punt), dun in de ophaal (↗/↙ langs de punt) - het klassieke dik-dun
van een echte vulpen. Druk (stylus) versterkt het geheel nog. */
const dx = x - bx, dy = y - by;
let target = PEN.nib;
if(dx || dy){
const dir = Math.atan2(dy, dx);
const nib = Math.abs(Math.sin(dir - Math.PI/4));
target = PEN.size*(0.35 + nib*2.1);
}
if(ptype==="pen" && pressure > 0) target *= (0.55 + pressure*0.9);
PEN.nib += (target - PEN.nib)*0.35;
w = PEN.nib;
}
ctx.lineWidth = w*bDpr;
/* vloeiende haal: teken van middelpunt naar middelpunt met het vorige
punt als stuurpunt (kwadratische curve) - geen hoekige knikken meer
tussen de losse pointer-stapjes, ook bij snelle halen */
const mx = (bx + x) / 2, my = (by + y) / 2;
ctx.beginPath();
ctx.moveTo(bMidX ?? bx, bMidY ?? by);
ctx.quadraticCurveTo(bx, by, mx, my);
ctx.stroke();
bMidX = mx; bMidY = my;
bx = x; by = y;
}
function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } }
function setBoardInk(url){
bCtx.clearRect(0,0,bInk.width,bInk.height);
if(!url) return;
const im = new Image();
im.onload = ()=>bCtx.drawImage(im,0,0);
im.src = url;
}
refreshPenbar();
/* gallery */
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])=>{
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("boards")}}
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");
const fabMenu = document.getElementById("fabmenu");
function refreshFab(){
fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets");
fabMenu.querySelector('[data-f="anchor"] .fl').textContent = T("fabAnchor");
fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw");
fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind");
fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg");
fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen"));
}
fab.addEventListener("click", ()=>{
refreshFab();
fabMenu.classList.toggle("open");
const open = fabMenu.classList.contains("open");
fab.classList.toggle("open", open);
if(open) document.getElementById("boardStrip").classList.remove("open");
});
document.addEventListener("pointerdown", e=>{
if(!fabMenu.classList.contains("open")) return;
if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return;
fabMenu.classList.remove("open");
fab.classList.remove("open");
});
fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
renderTemplates(); renderGallery(); galleryWrap.classList.add("open");
});
fabMenu.querySelector('[data-f="anchor"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
makeWidget(REGISTRY.find(d=>d.id==="anchor"));
});
fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
setPen(!document.body.classList.contains("pen"));
});
fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
const def = REGISTRY.find(d=>d.id==="mind");
makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true });
});
/* image catalogue + upload onto the board */
fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
openImageCatalog(src=>addBoardImage(src,80,90,320));
});
function loadImgFile(f, x, y){
if(currentUser && typeof uploadImageToCatalog==="function"){
uploadImageToCatalog(f).then(image=>addBoardImage(image.src,x,y,320))
.catch(err=>alert(err.message||T("imgUploadFailed")));
return;
}
/* gasten kunnen nog lokaal werken; hun niet-opgeslagen bord krijgt een
tijdelijke, verkleinde data-URL en gebruikt geen serverquota. */
const keepPng = /png|gif/.test(f.type);
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
const s = Math.min(1, 900/Math.max(img.width, img.height));
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
}
/* drag & drop images from other websites or the computer onto the board */
board.addEventListener("dragover", e=>{ e.preventDefault(); });
board.addEventListener("drop", e=>{
e.preventDefault();
const zf = VZ();
const r = board.getBoundingClientRect();
const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70);
const dt = e.dataTransfer;
if(dt.files && dt.files.length){
[...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); });
return;
}
let url = null;
const html = dt.getData("text/html");
if(html){
const m = html.match(/
]+src\s*=\s*["']([^"']+)["']/i);
if(m) url = m[1];
}
if(!url){
const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0];
if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri;
else if(/^data:image\//.test(uri)) url = uri;
else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */
}
if(url) addBoardImage(url, x, y, 320);
});
function addBoardImage(src, x, y, w){
const d = document.createElement("div");
d.className = "bimg";
d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px";
const im = document.createElement("img");
im.src = src; im.draggable = false;
d.appendChild(im);
const xb = document.createElement("button");
xb.className = "dtool-x"; xb.textContent = "✕";
xb.addEventListener("click", ()=>{ d.remove(); markBoardChange(); });
d.appendChild(xb);
const rs = document.createElement("div");
rs.className = "bimg-rs";
rs.addEventListener("pointerdown", e=>{
e.preventDefault(); e.stopPropagation();
const zf = VZ();
const sw = d.offsetWidth, sx = e.clientX;
const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
d.appendChild(rs);
d.addEventListener("pointerdown", e=>{
if(e.target===xb || e.target===rs) return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
const zf = VZ();
const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop;
const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
board.appendChild(d);
markBoardChange();
return d;
}
document.addEventListener("langchange", refreshFab);
/* =========================================================
Multiple boards, organised in folders (quick nav next to +)
==========================================================*/
/* stabiel board-id, onafhankelijk van naam/positie - nodig zodat een toewijzing
aan een klas/leerling (zie pupil.js) een bord blijft vinden ook als het wordt
hernoemd of andere borden ervoor/erna worden toegevoegd/verwijderd */
function genBoardId(){ return "b" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); }
function newBoardEntry(name){ return { id: genBoardId(), name, data:null }; }
function freshBS(){
return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1",
boards:[newBoardEntry(LANG==="nl" ? "Bord 1" : "Board 1")] }],
f:0, b:0 };
}
let BS = freshBS();
function boardsFromData(data){
let bs;
if(data && data.boards && data.boards.folders && data.boards.folders.length) bs = data.boards;
else if(data && data.board){
bs = freshBS();
bs.folders[0].boards[0].data = data.board; /* migrate the old single board */
} else bs = freshBS();
/* borden opgeslagen vóór dit id-systeem bestond krijgen er hier alsnog één */
bs.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(!bo.id) bo.id = genBoardId(); }));
return bs;
}
function curSlot(){
if(BS.f >= BS.folders.length) BS.f = 0;
let fo = BS.folders[BS.f];
/* lege mappen bestaan sinds de verkenner: wijs dan uit naar een map mét
borden, of geef de actieve lege map alsnog een vers bord */
if(!fo.boards.length){
const fi = BS.folders.findIndex(f=>f.boards.length);
if(fi >= 0){ BS.f = fi; fo = BS.folders[BS.f]; }
else fo.boards.push(newBoardEntry(T("boardName")+" 1"));
}
if(BS.b >= fo.boards.length) BS.b = 0;
return fo.boards[BS.b];
}
function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; }
function stashCurrent(){ curSlot().data = serializeBoard(); }
function loadCurrentSlot(){
restoreBoard(curSlot().data || emptyBoardData());
updateBoardsUI();
}
function switchTo(f, b){
stashCurrent();
markBoardChange();
BS.f = f; BS.b = b;
loadCurrentSlot();
}
const btnFolders = document.getElementById("btnFolders");
/* ankerdiagram uit de bibliotheek openen vanuit de hoofdverkenner: plaatst
een nieuw ankerwidget op het huidige bord met dat diagram erin geladen */
function openAnchorOnBoard(entry){
const def = REGISTRY.find(d=>d.id==="anchor");
makeWidget(def, { state: {
cells: structuredClone(entry.cells || {}),
pics: structuredClone(entry.pics || {}),
}});
markBoardChange();
return null;
}
btnFolders.addEventListener("click", ()=>{
stashCurrent(); /* verse thumbnails, ook van het actieve bord */
openExplorer(withBranch(
"⚓ " + T("anchorLibTitle"),
withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks()),
withSharedBranches("anchor", anchorLibraryAdapter(openAnchorOnBoard), anchorLibrarySharedHooks(openAnchorOnBoard)),
{
wrongTypeMsg: T("exWrongBranch"),
branchCreateMsg: T("anchorLibCreateHint"),
branchCount: ()=>ANCHOR_LIB.length,
}
));
});
/* koppelstuk voor de gedeelde bibliotheek (zie withSharedBranches in
explorer.js): borden exporteren/importeren als {name, data}. Een kopie
krijgt altijd een nieuw bord-id en verse wids, anders zouden toewijzingen
en voortgang van verschillende gebruikers naar hetzelfde id verwijzen. */
function freshBoardCopy(name, data){
const d = data ? structuredClone(data) : null;
if(d && Array.isArray(d.widgets)) d.widgets.forEach(w=>{ w.wid = genWidgetId(); });
return { id: genBoardId(), name, data: d };
}
function boardsSharedHooks(){
const entryAtPath = path => BS.folders.find(f=>joinBoardPath(splitBoardPath(f.name))===joinBoardPath(path));
const ensureEntry = path => {
let e = entryAtPath(path);
if(!e){ e = { name: joinBoardPath(path), boards: [] }; BS.folders.push(e); }
return e;
};
const boardData = hit => (hit.fi===BS.f && hit.bi===BS.b) ? serializeBoard() : (hit.bo.data || emptyBoardData());
return {
exportItem(id){
const hit = findBoardEntry(id);
return hit ? { name: hit.bo.name, data: boardData(hit) } : null;
},
exportFolder(path){
const p = joinBoardPath(path);
const out = [];
BS.folders.forEach((f, fi)=>{
const j = joinBoardPath(splitBoardPath(f.name));
if(j === p || j.startsWith(p + "/")){
const rel = j === p ? [] : j.slice(p.length + 1).split("/");
f.boards.forEach((bo, bi)=>out.push({
name: bo.name, rel,
data: (fi===BS.f && bi===BS.b) ? serializeBoard() : (bo.data || emptyBoardData()),
}));
}
});
return out;
},
copyIn(item, destPath){
if(!destPath.length) return T("exNoRootItems");
ensureEntry(destPath).boards.push(freshBoardCopy(item.name, item.data));
markBoardChange();
return null;
},
openShared(item, relPath){
const entry = ensureEntry(relPath.length ? relPath : [T("shGlobal")]);
const copy = freshBoardCopy(item.name, item.data);
entry.boards.push(copy);
markBoardChange();
const hit = findBoardEntry(copy.id);
if(hit) switchTo(hit.fi, hit.bi);
return null;
},
};
}
function updateBoardsUI(){
btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`;
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
if(boardStrip.classList.contains("open")) renderStrip();
}
/* ---- verkenner-adapter: virtuele boom over de platte BS.folders-lijst;
nesting zit in de mapnaam als pad met "/"-scheiding ("Rekenen/Groep 4"),
dus bestaande data en de server (findBoardById op bord-id) blijven werken */
const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean);
const joinBoardPath = p => p.join("/");
function findBoardEntry(id){
for(let fi=0; fib.id===id);
if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] };
}
return null;
}
function boardsExplorerAdapter(){
const norm = f => joinBoardPath(splitBoardPath(f.name));
const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path));
const findBoard = findBoardEntry;
const activeId = ()=>curSlot().id;
/* indices herstellen nadat entries verschoven/verwijderd zijn */
const relocate = (id)=>{
const hit = findBoard(id);
if(hit){ BS.f = hit.fi; BS.b = hit.bi; }
};
const mutated = ()=>{ markBoardChange(); updateBoardsUI(); };
return {
title: ()=>T("boardsNav"),
allowRootItems: false,
newItemLabel: ()=>T("newBoard"),
list(path){
const p = joinBoardPath(path);
const folderMap = new Map();
let items = [];
BS.folders.forEach((f, fi)=>{
const j = norm(f);
if(j === p){
items = f.boards.map((bo, bi)=>({
key: bo.id, name: bo.name,
active: fi===BS.f && bi===BS.b,
tile: ()=>boardThumb((fi===BS.f && bi===BS.b) ? serializeBoard() : bo.data),
}));
}else if(!p || j.startsWith(p + "/")){
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
if(seg) folderMap.set(seg, (folderMap.get(seg)||0) + f.boards.length);
}
});
const folders = [...folderMap].map(([name, count])=>({name, count}))
.sort((a,b)=>a.name.localeCompare(b.name));
return { folders, items };
},
createFolder(path, name){
const full = [...path, name];
if(entryAt(full) || this.list(path).folders.some(f=>f.name===name)) return T("exExists");
BS.folders.push({ name: joinBoardPath(full), boards: [] });
mutated();
return null;
},
renameFolder(path, oldName, newName){
return this.moveFolder([...path, oldName], path, newName);
},
moveFolder(srcPath, targetPath, newName){
const from = joinBoardPath(srcPath);
const to = joinBoardPath([...targetPath, newName || srcPath[srcPath.length-1]]);
if(from === to) return null;
if(BS.folders.some(f=>{ const j = norm(f); return j === to || j.startsWith(to + "/"); })) return T("exExists");
const id = activeId();
BS.folders.forEach(f=>{
const j = norm(f);
if(j === from || j.startsWith(from + "/")) f.name = to + j.slice(from.length);
});
relocate(id);
mutated();
return null;
},
deleteFolder(path, name){
const full = joinBoardPath([...path, name]);
const under = BS.folders.filter(f=>{ const j = norm(f); return j === full || j.startsWith(full + "/"); });
if(under.some(f=>f.boards.length)) return T("exFolderNotEmpty");
const id = activeId();
BS.folders = BS.folders.filter(f=>!under.includes(f));
if(!BS.folders.length) BS.folders.push({ name: T("folderName")+" 1", boards: [] });
relocate(id);
mutated();
return null;
},
createItem(path){
let entry = entryAt(path);
if(!entry){ entry = { name: joinBoardPath(path), boards: [] }; BS.folders.push(entry); }
entry.boards.push(newBoardEntry(`${T("boardName")} ${entry.boards.length+1}`));
mutated();
return null;
},
renameItem(id, name){
const hit = findBoard(id);
if(!hit) return null;
hit.bo.name = name;
mutated();
return null;
},
moveItem(id, targetPath){
const hit = findBoard(id);
if(!hit) return null;
let entry = entryAt(targetPath);
if(!entry){ entry = { name: joinBoardPath(targetPath), boards: [] }; BS.folders.push(entry); }
if(entry === hit.fo) return null;
const act = activeId();
hit.fo.boards.splice(hit.bi, 1);
entry.boards.push(hit.bo);
relocate(act);
mutated();
return null;
},
deleteItem(id){
const hit = findBoard(id);
if(!hit) return null;
if(flatBoards().length <= 1) return T("exLastBoard");
const wasActive = id === activeId();
hit.fo.boards.splice(hit.bi, 1);
if(wasActive){ BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else relocate(activeId());
mutated();
return null;
},
openItem(id){
const hit = findBoard(id);
if(hit && !(hit.fi===BS.f && hit.bi===BS.b)) switchTo(hit.fi, hit.bi);
},
};
}
function flatBoards(){
const out = [];
BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo})));
return out;
}
/* ---- quick switch: hover the 🗂 button → previews side by side ---- */
const fabBoardsBtn = document.getElementById("fabBoardsBtn");
const boardStrip = document.getElementById("boardStrip");
let stripTimer = null;
function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); }
function closeStrip(){ boardStrip.classList.remove("open"); }
fabBoardsBtn.addEventListener("click", ()=>{
boardStrip.classList.contains("open") ? closeStrip() : openStrip();
});
fabBoardsBtn.addEventListener("mouseenter", openStrip);
fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer));
boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
document.addEventListener("pointerdown", e=>{
if(!boardStrip.classList.contains("open")) return;
if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return;
closeStrip();
});
boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; });
/* mini preview of a board's contents */
function boardThumb(data){
const w = 168, h = 105;
const c = document.createElement("canvas");
c.width = w; c.height = h;
const g = c.getContext("2d");
g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
if(!data) return c;
const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1);
(data.imgs||[]).forEach(im=>{
g.fillStyle = "#d8e2ef";
g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh));
});
(data.widgets||[]).forEach(wd=>{
const x = wd.x*w/bw, y = wd.y*h/bh,
ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh);
g.fillStyle = "rgba(59,125,216,.14)";
g.fillRect(x, y, ww, wh);
g.strokeStyle = "rgba(59,125,216,.55)";
g.strokeRect(x, y, ww, wh);
const def = REGISTRY.find(d=>d.id===wd.id);
if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); }
});
if(data.ink){
const img = new Image();
img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} };
img.src = data.ink;
}
return c;
}
function renderStrip(){
boardStrip.innerHTML = "";
const items = flatBoards();
items.forEach(it=>{
const isCur = it.fi===BS.f && it.bi===BS.b;
const card = document.createElement("div");
card.className = "bs-card" + (isCur ? " active" : "");
card.title = `${it.fo.name} / ${it.bo.name}`;
card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data));
const row = document.createElement("div");
row.className = "bs-name";
const nm = document.createElement("span");
nm.textContent = it.bo.name;
row.appendChild(nm);
if(items.length > 1){
const x = document.createElement("button");
x.className = "bs-x"; x.textContent = "✕";
x.title = T("delBoard");
x.addEventListener("pointerdown", ev=>ev.stopPropagation());
x.addEventListener("click", ev=>{
ev.stopPropagation();
if(!x.dataset.arm){ /* first click arms, second click deletes */
clearTimeout(stripTimer); /* keep the strip open while confirming */
x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?";
setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000);
return;
}
const fo = BS.folders[it.fi];
fo.boards.splice(it.bi, 1);
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
markBoardChange();
renderStrip();
});
row.appendChild(x);
}
card.appendChild(row);
card.addEventListener("click", ()=>{
if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); }
});
boardStrip.appendChild(card);
});
const add = document.createElement("div");
add.className = "bs-card bs-new";
add.textContent = "+";
add.title = T("newBoard");
add.addEventListener("click", ()=>{
const fo = BS.folders[BS.f];
fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`));
switchTo(BS.f, fo.boards.length-1);
renderStrip();
});
boardStrip.appendChild(add);
}
document.addEventListener("langchange", updateBoardsUI);