/* 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(); 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 = [
{ 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:"anchor", cat:"taal", icon:"β", w:680, h:500, minW:440, minH:340, mount:mountAnchor },
{ 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:520, h:430, minW:360, minH:300, mount:mountMoney },
{ id:"wordtypes", cat:"taal", icon:"π¨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes },
{ id:"write", cat:"tools", icon:"βοΈ", w:560, h:440, minW:360, minH:280, mount:mountWrite },
{ id:"mind", cat:"tools", icon:"πΈοΈ", w:640, h:480, minW:420, minH:320, mount:mountMind },
{ id:"notes", cat:"tools", icon:"π", w:340, h:300, minW:240, minH:180, mount:mountNotes },
{ id:"timer", cat:"tools", icon:"β±οΈ", w:350, h:470, minW:300, minH:420, mount:mountTimer },
{ id:"dice", cat:"tools", icon:"π²", w:400, h:340, minW:300, minH:270, mount:mountDice },
{ id:"names", cat:"tools", icon:"π―", w:420, h:430, minW:320, minH:330, mount:mountNames },
{ id:"birthday",cat:"class", icon:"π", w:620, h:560, minW:400, minH:400, mount:mountBirthday },
{ id:"schedule", cat:"class", icon:"π
", w:500, h:520, minW:340, minH:340, mount:mountSchedule },
{ id:"mood", cat:"class", icon:"π‘οΈ", w:530, h:360, minW:350, minH:260, mount:mountMood },
{ id:"poll", cat:"class", icon:"π", w:540, h:500, minW:360, minH:330, mount:mountPoll },
{ id:"media", cat:"media", icon:"π¬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder
dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */
function clampWidgetSize(w, h, def){
const fw = Math.max(def.minW || 240, Math.min(w, board.clientWidth));
const fh = Math.max(def.minH || 180, Math.min(h, board.clientHeight));
return { w: fw, h: fh };
}
/* 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";
const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def);
win.style.width = size.w+"px";
win.style.height = size.h+"px";
if(def.minW) win.style.minWidth = def.minW+"px";
if(def.minH) win.style.minHeight = def.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 maxW = board.clientWidth - win.offsetLeft - 8;
const maxH = board.clientHeight - win.offsetTop - 8;
let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf;
nw = Math.max(def.minW || 240, Math.min(nw, maxW));
nh = Math.max(def.minH || 180, 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");
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;
}
}
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 = "β’";
}
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"); }
};
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);
close.addEventListener("click", ()=>{
markBoardChange();
if(pill){ pill.remove(); pill = null; }
instances.delete(inst); win.remove(); updateEmptyHint();
});
updateEmptyHint();
markBoardChange();
return inst;
}
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");
const bDpr = window.devicePixelRatio || 1;
function bResize(){
const w = board.clientWidth, h = board.clientHeight;
if(w<2 || h<2) return;
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";
}
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();
[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;
penbar.querySelector('[data-picker="backgrounds"] .pen-current').title = T(PEN_BGS.find(x=>x[0]===BOARD.bg)[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();
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;
const mv = ev=>{
ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start;
t.style.transform = `rotate(${ang}deg)`;
};
const up = ()=>{ 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"){
t._len = 600; t._hc = false;
const drawRuler = ()=>{
const L = t._len, 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=5; i`;
if(cm) ticks += `${i/50}`;
}
t.style.width = L+"px";
t.querySelector(".dtool-svg").innerHTML = ``;
};
t.innerHTML = `
`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._len = t._len===600 ? 800 : t._len===800 ? 400 : 600; drawRuler();
});
t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); });
t.style.height = "76px";
drawRuler();
}else if(kind==="geo"){
t._sz = 340; t._hc = false;
const drawGeo = ()=>{
const W = t._sz, H = W/2 + 22, hc = t._hc;
const ink = hc ? "#000" : "#1e4f74";
const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.6)";
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 + baseline */
const half = W/2 - 14;
let cmTicks = "";
for(let i=25; i<=half-16; i+=25){
const cm = i%50===0;
[ax-i, ax+i].forEach(x=>{
cmTicks += ``;
});
if(cm){
[ax-i, ax+i].forEach(x=>{
cmTicks += `${i/50}`;
});
}
}
/* degree arc with labels INSIDE the arc so they stay readable */
let degs = "";
const R = half*0.60;
for(let d=10; d<180; d+=10){
const rad = d*Math.PI/180;
const x1 = ax - Math.cos(rad)*(R-7), y1 = by - Math.sin(rad)*(R-7);
const x2 = ax - Math.cos(rad)*R, y2 = by - Math.sin(rad)*R;
degs += ``;
if(d%30===0 && d>=30 && d<=150){
const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22);
degs += `${d}Β°`;
}
}
t.style.width = W+"px"; t.style.height = H+"px";
t.querySelector(".dtool-svg").innerHTML = ``;
};
t.innerHTML = `
`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._sz = t._sz===340 ? 440 : t._sz===440 ? 280 : 340; drawGeo();
});
t.querySelector(".do-hc").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);
}
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;
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)];
};
bInk.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
if(PEN.tool==="text"){ startBoardText(e); return; }
bInk.setPointerCapture(e.pointerId);
bDraw = true;
PEN.nib = PEN.size*0.8;
[bx,by] = bPos(e);
bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType);
});
bInk.addEventListener("pointermove", e=>{
if(!bDraw) return;
(e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{
const [x,y] = bPos(ev); bStroke(x, y, ev.pressure, ev.pointerType);
});
});
const bStop = ()=>{
if(bDraw) markBoardChange();
bDraw = false;
bCtx.globalAlpha = 1;
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
};
bInk.addEventListener("pointerup", bStop);
bInk.addEventListener("pointercancel", bStop);
function bStroke(x,y,pressure,ptype){
bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over";
bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1;
bCtx.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"){
let target;
if(ptype==="pen" && pressure > 0){
target = PEN.size*(0.45 + pressure*1.9);
}else{
const speed = Math.hypot(x-bx, y-by)/bDpr;
target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12));
}
PEN.nib += (target - PEN.nib)*0.3;
w = PEN.nib;
}
bCtx.lineWidth = w*bDpr;
bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke();
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()}}
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="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="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 upload onto the board */
const imgInput = document.createElement("input");
imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none";
document.body.appendChild(imgInput);
fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
imgInput.click();
});
function loadImgFile(f, x, y){
const keepPng = /png|gif|svg/.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);
}
imgInput.addEventListener("change", ()=>{
const f = imgInput.files[0]; if(!f) return;
loadImgFile(f, 80, 90);
imgInput.value = "";
});
/* 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;
const fo = BS.folders[BS.f];
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 boardsPanel = document.getElementById("boardsPanel");
const btnFolders = document.getElementById("btnFolders");
btnFolders.addEventListener("click", ()=>{
renderBoardsPanel();
boardsPanel.classList.toggle("open");
});
document.addEventListener("pointerdown", e=>{
if(!boardsPanel.classList.contains("open")) return;
if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return;
boardsPanel.classList.remove("open");
});
function updateBoardsUI(){
btnFolders.title = `${T("boardsNav")} Β· ${BS.folders[BS.f].name} / ${curSlot().name}`;
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
if(boardsPanel.classList.contains("open")) renderBoardsPanel();
if(boardStrip.classList.contains("open")) renderStrip();
}
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);
}
function renderBoardsPanel(){
boardsPanel.innerHTML = "";
BS.folders.forEach((fo, fi)=>{
const fr = document.createElement("div");
fr.className = "bp-folder";
const nm = document.createElement("span");
nm.className = "bpf-name"; nm.textContent = "π " + fo.name;
fr.appendChild(nm);
const add = document.createElement("button");
add.textContent = T("newBoard");
add.addEventListener("click", ()=>{
fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`));
switchTo(fi, fo.boards.length-1);
renderBoardsPanel();
});
fr.appendChild(add);
if(BS.folders.length > 1){
const del = document.createElement("button");
del.textContent = "π";
del.addEventListener("click", ()=>{
if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; }
markBoardChange();
renderBoardsPanel();
});
fr.appendChild(del);
}
boardsPanel.appendChild(fr);
const row = document.createElement("div");
row.className = "bp-boards";
fo.boards.forEach((bo, bi)=>{
const chip = document.createElement("div");
chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : "");
const lbl = document.createElement("span");
lbl.textContent = bo.name;
chip.appendChild(lbl);
chip.addEventListener("click", e=>{
if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return;
if(fi===BS.f && bi===BS.b) return;
switchTo(fi, bi);
renderBoardsPanel();
});
chip.addEventListener("dblclick", ()=>{
if(chip.querySelector("input")) return;
const inp = document.createElement("input");
inp.value = bo.name; inp.maxLength = 20;
chip.replaceChild(inp, lbl);
inp.focus(); inp.select();
let done = false;
const commit = ()=>{
if(done) return; done = true;
bo.name = inp.value.trim() || bo.name;
markBoardChange();
renderBoardsPanel(); updateBoardsUI();
};
inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); });
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
});
if(fo.boards.length > 1 || BS.folders.length > 1){
const x = document.createElement("button");
x.className = "bpb-x"; x.textContent = "β";
x.addEventListener("click", ev=>{
ev.stopPropagation();
const wasCur = (fi===BS.f && bi===BS.b);
fo.boards.splice(bi,1);
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(wasCur){ BS.b = 0; loadCurrentSlot(); }
else if(fi===BS.f && bi < BS.b) BS.b--;
markBoardChange();
renderBoardsPanel();
});
chip.appendChild(x);
}
row.appendChild(chip);
});
boardsPanel.appendChild(row);
});
const nf = document.createElement("div");
nf.className = "bp-new";
const inp = document.createElement("input");
inp.placeholder = T("folderPh"); inp.maxLength = 20;
const addFolder = ()=>{
const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`;
BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] });
inp.value = "";
markBoardChange();
renderBoardsPanel();
};
inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); });
const nb = document.createElement("button");
nb.className = "tbtn"; nb.textContent = T("newFolder");
nb.addEventListener("click", addFolder);
nf.appendChild(inp); nf.appendChild(nb);
boardsPanel.appendChild(nf);
const hint = document.createElement("div");
hint.className = "bp-hint"; hint.textContent = T("renameTip");
boardsPanel.appendChild(hint);
}
document.addEventListener("langchange", updateBoardsUI);