teach/public/js/board.js
Ramon 18775ae0ec v0.3.20-beta: hernoem mappen en verplaats borden tussen mappen
- Bordenpaneel: dubbelklik op een mapnaam hernoemt de map (zelfde
  inline-input-patroon als bord hernoemen)
- Nieuw 📁-knopje per bord toont een inline select met de overige mappen;
  bij keuze verhuist het bord, het actieve bord volgt mee (BS.f/BS.b
  worden gecorrigeerd) en een leeggeraakte map krijgt een vers bord
- renameTip vermeldt nu ook mappen; nieuwe i18n-tekst moveBoardTip (nl/en)
2026-07-14 16:29:25 +02:00

1296 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = [
{ 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 = `<span class="wicon">${def.icon}</span><span class="wtitle"></span>`;
const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn);
const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn);
const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn);
const zBtn = document.createElement("button");
zBtn.className = "wmin wz"; zBtn.textContent = "A";
head.appendChild(zBtn);
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 = `<span class="di">${def.icon}</span><span class="dl"></span>`;
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<L; i+=5){
const cm = i%50===0, half = i%25===0;
const len = cm ? 22 : half ? 14 : 8;
ticks += `<line x1="${i}" y1="2" x2="${i}" y2="${2+len}" stroke="${ink}" stroke-width="${cm ? (hc?2.5:1.6) : (hc?1.6:1)}"/>`;
if(cm) ticks += `<text x="${i}" y="${hc?46:42}" font-size="${hc?18:14}" font-weight="bold" fill="${ink}" text-anchor="middle">${i/50}</text>`;
}
t.style.width = L+"px";
t.querySelector(".dtool-svg").innerHTML = `<svg viewBox="0 0 ${L} 76" width="${L}" height="76">
<defs><linearGradient id="rg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgba(255,255,255,.65)"/><stop offset="1" stop-color="rgba(0,0,0,.06)"/>
</linearGradient></defs>
<rect x="0" y="0" width="${L}" height="76" rx="9" fill="${bg}" stroke="${ink}" stroke-width="${hc?3:1.5}"/>
<rect x="0" y="0" width="${L}" height="76" rx="9" fill="url(#rg)"/>
<line x1="0" y1="2" x2="${L}" y2="2" stroke="${ink}" stroke-width="${hc?3:2}"/>
<text x="${L/2}" y="66" font-size="12" fill="${ink}" text-anchor="middle" opacity=".65">teach · cm</text>
${ticks}</svg>`;
};
t.innerHTML = `<div class="dtool-svg"></div>
<div class="dtool-opts">
<button class="do-len" title="↔">↔</button>
<button class="do-hc" title="◐">◐</button>
</div>`;
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 += `<line x1="${x}" y1="${by}" x2="${x}" y2="${by-(cm?13:7)}" stroke="${ink}" stroke-width="${cm?(hc?2.2:1.4):1}"/>`;
});
if(cm){
[ax-i, ax+i].forEach(x=>{
cmTicks += `<text x="${x}" y="${by-18}" font-size="${hc?15:12}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${i/50}</text>`;
});
}
}
/* 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 += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${ink}" stroke-width="${d%30===0?(hc?2.2:1.5):1}"/>`;
if(d%30===0 && d>=30 && d<=150){
const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22);
degs += `<text x="${xt}" y="${yt+4}" font-size="${hc?14:11.5}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${d}°</text>`;
}
}
t.style.width = W+"px"; t.style.height = H+"px";
t.querySelector(".dtool-svg").innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
<polygon points="14,${by} ${W-14},${by} ${ax},${ay}" fill="${bg}" stroke="${ink}" stroke-width="${hc?3:2}"/>
<path d="M ${ax-R} ${by} A ${R} ${R} 0 0 1 ${ax+R} ${by}" fill="none" stroke="${ink}" stroke-width="1.2"/>
<line x1="${ax}" y1="${by}" x2="${ax}" y2="${ay+18}" stroke="${ink}" stroke-width="1" stroke-dasharray="4 4"/>
<text x="${ax}" y="${by-6}" font-size="${hc?13:10}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">0</text>
${cmTicks}${degs}</svg>`;
};
t.innerHTML = `<div class="dtool-svg"></div>
<div class="dtool-opts">
<button class="do-len">↔</button>
<button class="do-hc">◐</button>
</div>`;
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 = `<div class="tl-house tl-mini">
<button class="tl-lamp red" data-c="red"></button>
<button class="tl-lamp orange" data-c="orange"></button>
<button class="tl-lamp green" data-c="green"></button>
</div>`;
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("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="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(/<img[^>]+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;
/* map hernoemen: zelfde dblclick-inline-input-patroon als bord hernoemen */
nm.addEventListener("dblclick", ()=>{
if(fr.querySelector("input")) return;
const inp = document.createElement("input");
inp.value = fo.name; inp.maxLength = 20;
fr.replaceChild(inp, nm);
inp.focus(); inp.select();
let done = false;
const commit = ()=>{
if(done) return; done = true;
fo.name = inp.value.trim() || fo.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());
});
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());
});
/* bord verplaatsen naar een andere map: klik toont een inline select
met de overige mappen; het actieve bord verhuist mee (indices volgen) */
if(BS.folders.length > 1){
const mv = document.createElement("button");
mv.className = "bpb-x bpb-mv"; mv.textContent = "📁";
mv.title = T("moveBoardTip");
mv.addEventListener("click", ev=>{
ev.stopPropagation();
if(chip.querySelector("select")) return;
const sel = document.createElement("select");
sel.className = "bpb-mv-sel";
const ph = document.createElement("option");
ph.value = ""; ph.textContent = T("moveBoardTip");
sel.appendChild(ph);
BS.folders.forEach((f2, f2i)=>{
if(f2i === fi) return;
const o = document.createElement("option");
o.value = String(f2i); o.textContent = "📁 " + f2.name;
sel.appendChild(o);
});
sel.addEventListener("click", e2=>e2.stopPropagation());
sel.addEventListener("pointerdown", e2=>e2.stopPropagation());
sel.addEventListener("blur", ()=>sel.remove());
sel.addEventListener("change", ()=>{
if(sel.value === "") return;
const ti = +sel.value;
const wasCur = (fi===BS.f && bi===BS.b);
fo.boards.splice(bi,1);
BS.folders[ti].boards.push(bo);
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(wasCur){ BS.f = ti; BS.b = BS.folders[ti].boards.length-1; }
else if(fi===BS.f && bi < BS.b) BS.b--;
markBoardChange();
renderBoardsPanel(); updateBoardsUI();
});
chip.appendChild(sel);
sel.focus();
});
chip.appendChild(mv);
}
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);