teach/public/js/board.js
Ramon 1151c71049 v0.3.22-beta: Windows-stijl mappenverkenner voor borden
- Nieuw public/js/explorer.js: generieke verkenner-modal met broodkruimelpad,
  werkbalk (nieuwe map/nieuw item/hernoem/verwijder met bevestigingsstap),
  raster van map- en itemtegels, dubbelklik om te openen en slepen om te
  verplaatsen (pointer-events, werkt op touch; drop-doel licht op)
- Geneste mappen zonder datamigratie: nesting zit in de mapnaam als pad met
  "/"-scheiding; server en toewijzingen (bord-id's) blijven onaangeroerd;
  "/" wordt geweigerd in nieuwe namen
- Borden-adapter in board.js: bordtegels met boardThumb-preview, actief bord
  gemarkeerd en gevolgd bij verplaatsen/hernoemen (prefix-rewrite), map
  verwijderen alleen als leeg, laatste bord onverwijderbaar
- 📁-knop in de menubalk opent de verkenner; het oude mappenpaneel
  (renderBoardsPanel + #boardsPanel + CSS) is vervangen; de boardStrip-
  snelwissel blijft; curSlot() heelt lege actieve mappen zelf
2026-07-14 21:58:24 +02:00

1266 lines
60 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;
let fo = BS.folders[BS.f];
/* lege mappen bestaan sinds de verkenner: wijs dan uit naar een map mét
borden, of geef de actieve lege map alsnog een vers bord */
if(!fo.boards.length){
const fi = BS.folders.findIndex(f=>f.boards.length);
if(fi >= 0){ BS.f = fi; fo = BS.folders[BS.f]; }
else fo.boards.push(newBoardEntry(T("boardName")+" 1"));
}
if(BS.b >= fo.boards.length) BS.b = 0;
return fo.boards[BS.b];
}
function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; }
function stashCurrent(){ curSlot().data = serializeBoard(); }
function loadCurrentSlot(){
restoreBoard(curSlot().data || emptyBoardData());
updateBoardsUI();
}
function switchTo(f, b){
stashCurrent();
markBoardChange();
BS.f = f; BS.b = b;
loadCurrentSlot();
}
const btnFolders = document.getElementById("btnFolders");
btnFolders.addEventListener("click", ()=>{
stashCurrent(); /* verse thumbnails, ook van het actieve bord */
openExplorer(boardsExplorerAdapter());
});
function updateBoardsUI(){
btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`;
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
if(boardStrip.classList.contains("open")) renderStrip();
}
/* ---- verkenner-adapter: virtuele boom over de platte BS.folders-lijst;
nesting zit in de mapnaam als pad met "/"-scheiding ("Rekenen/Groep 4"),
dus bestaande data en de server (findBoardById op bord-id) blijven werken */
const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean);
const joinBoardPath = p => p.join("/");
function boardsExplorerAdapter(){
const norm = f => joinBoardPath(splitBoardPath(f.name));
const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path));
const findBoard = id => {
for(let fi=0; fi<BS.folders.length; fi++){
const bi = BS.folders[fi].boards.findIndex(b=>b.id===id);
if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] };
}
return null;
};
const activeId = ()=>curSlot().id;
/* indices herstellen nadat entries verschoven/verwijderd zijn */
const relocate = (id)=>{
const hit = findBoard(id);
if(hit){ BS.f = hit.fi; BS.b = hit.bi; }
};
const mutated = ()=>{ markBoardChange(); updateBoardsUI(); };
return {
title: ()=>T("boardsNav"),
allowRootItems: false,
newItemLabel: ()=>T("newBoard"),
list(path){
const p = joinBoardPath(path);
const folderMap = new Map();
let items = [];
BS.folders.forEach((f, fi)=>{
const j = norm(f);
if(j === p){
items = f.boards.map((bo, bi)=>({
key: bo.id, name: bo.name,
active: fi===BS.f && bi===BS.b,
tile: ()=>boardThumb((fi===BS.f && bi===BS.b) ? serializeBoard() : bo.data),
}));
}else if(!p || j.startsWith(p + "/")){
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
if(seg) folderMap.set(seg, (folderMap.get(seg)||0) + f.boards.length);
}
});
const folders = [...folderMap].map(([name, count])=>({name, count}))
.sort((a,b)=>a.name.localeCompare(b.name));
return { folders, items };
},
createFolder(path, name){
const full = [...path, name];
if(entryAt(full) || this.list(path).folders.some(f=>f.name===name)) return T("exExists");
BS.folders.push({ name: joinBoardPath(full), boards: [] });
mutated();
return null;
},
renameFolder(path, oldName, newName){
return this.moveFolder([...path, oldName], path, newName);
},
moveFolder(srcPath, targetPath, newName){
const from = joinBoardPath(srcPath);
const to = joinBoardPath([...targetPath, newName || srcPath[srcPath.length-1]]);
if(from === to) return null;
if(BS.folders.some(f=>{ const j = norm(f); return j === to || j.startsWith(to + "/"); })) return T("exExists");
const id = activeId();
BS.folders.forEach(f=>{
const j = norm(f);
if(j === from || j.startsWith(from + "/")) f.name = to + j.slice(from.length);
});
relocate(id);
mutated();
return null;
},
deleteFolder(path, name){
const full = joinBoardPath([...path, name]);
const under = BS.folders.filter(f=>{ const j = norm(f); return j === full || j.startsWith(full + "/"); });
if(under.some(f=>f.boards.length)) return T("exFolderNotEmpty");
const id = activeId();
BS.folders = BS.folders.filter(f=>!under.includes(f));
if(!BS.folders.length) BS.folders.push({ name: T("folderName")+" 1", boards: [] });
relocate(id);
mutated();
return null;
},
createItem(path){
let entry = entryAt(path);
if(!entry){ entry = { name: joinBoardPath(path), boards: [] }; BS.folders.push(entry); }
entry.boards.push(newBoardEntry(`${T("boardName")} ${entry.boards.length+1}`));
mutated();
return null;
},
renameItem(id, name){
const hit = findBoard(id);
if(!hit) return null;
hit.bo.name = name;
mutated();
return null;
},
moveItem(id, targetPath){
const hit = findBoard(id);
if(!hit) return null;
let entry = entryAt(targetPath);
if(!entry){ entry = { name: joinBoardPath(targetPath), boards: [] }; BS.folders.push(entry); }
if(entry === hit.fo) return null;
const act = activeId();
hit.fo.boards.splice(hit.bi, 1);
entry.boards.push(hit.bo);
relocate(act);
mutated();
return null;
},
deleteItem(id){
const hit = findBoard(id);
if(!hit) return null;
if(flatBoards().length <= 1) return T("exLastBoard");
const wasActive = id === activeId();
hit.fo.boards.splice(hit.bi, 1);
if(wasActive){ BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else relocate(activeId());
mutated();
return null;
},
openItem(id){
const hit = findBoard(id);
if(hit && !(hit.fi===BS.f && hit.bi===BS.b)) switchTo(hit.fi, hit.bi);
},
};
}
function flatBoards(){
const out = [];
BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo})));
return out;
}
/* ---- quick switch: hover the 🗂 button → previews side by side ---- */
const fabBoardsBtn = document.getElementById("fabBoardsBtn");
const boardStrip = document.getElementById("boardStrip");
let stripTimer = null;
function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); }
function closeStrip(){ boardStrip.classList.remove("open"); }
fabBoardsBtn.addEventListener("click", ()=>{
boardStrip.classList.contains("open") ? closeStrip() : openStrip();
});
fabBoardsBtn.addEventListener("mouseenter", openStrip);
fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer));
boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
document.addEventListener("pointerdown", e=>{
if(!boardStrip.classList.contains("open")) return;
if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return;
closeStrip();
});
boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; });
/* mini preview of a board's contents */
function boardThumb(data){
const w = 168, h = 105;
const c = document.createElement("canvas");
c.width = w; c.height = h;
const g = c.getContext("2d");
g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
if(!data) return c;
const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1);
(data.imgs||[]).forEach(im=>{
g.fillStyle = "#d8e2ef";
g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh));
});
(data.widgets||[]).forEach(wd=>{
const x = wd.x*w/bw, y = wd.y*h/bh,
ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh);
g.fillStyle = "rgba(59,125,216,.14)";
g.fillRect(x, y, ww, wh);
g.strokeStyle = "rgba(59,125,216,.55)";
g.strokeRect(x, y, ww, wh);
const def = REGISTRY.find(d=>d.id===wd.id);
if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); }
});
if(data.ink){
const img = new Image();
img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} };
img.src = data.ink;
}
return c;
}
function renderStrip(){
boardStrip.innerHTML = "";
const items = flatBoards();
items.forEach(it=>{
const isCur = it.fi===BS.f && it.bi===BS.b;
const card = document.createElement("div");
card.className = "bs-card" + (isCur ? " active" : "");
card.title = `${it.fo.name} / ${it.bo.name}`;
card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data));
const row = document.createElement("div");
row.className = "bs-name";
const nm = document.createElement("span");
nm.textContent = it.bo.name;
row.appendChild(nm);
if(items.length > 1){
const x = document.createElement("button");
x.className = "bs-x"; x.textContent = "✕";
x.title = T("delBoard");
x.addEventListener("pointerdown", ev=>ev.stopPropagation());
x.addEventListener("click", ev=>{
ev.stopPropagation();
if(!x.dataset.arm){ /* first click arms, second click deletes */
clearTimeout(stripTimer); /* keep the strip open while confirming */
x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?";
setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000);
return;
}
const fo = BS.folders[it.fi];
fo.boards.splice(it.bi, 1);
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
markBoardChange();
renderStrip();
});
row.appendChild(x);
}
card.appendChild(row);
card.addEventListener("click", ()=>{
if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); }
});
boardStrip.appendChild(card);
});
const add = document.createElement("div");
add.className = "bs-card bs-new";
add.textContent = "";
add.title = T("newBoard");
add.addEventListener("click", ()=>{
const fo = BS.folders[BS.f];
fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`));
switchTo(BS.f, fo.boards.length-1);
renderStrip();
});
boardStrip.appendChild(add);
}
document.addEventListener("langchange", updateBoardsUI);