teach/public/js/board.js
Ramon d864299ea6
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
v0.3.02-beta: maak tekenwerkbalk compact
2026-07-14 10:57:56 +02:00

1190 lines
51 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 */
const markBoardChange = ()=>{ if(!RESTORING) scheduleSave(); };
/* 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:"madd", cat:"rekenen", icon:"", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") },
{ id:"msub", cat:"rekenen", icon:"", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") },
{ id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") },
{ id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") },
{ id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") },
{ 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:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
];
const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["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 };
}
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"; }
const head = document.createElement("div");
head.className = "widget-head";
head.innerHTML = `<span class="wicon">${def.icon}</span><span class="wtitle"></span>`;
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=>{
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);
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(e.target===close) 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 };
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,
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",
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;
}
/* ---------- 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 = "";
WIDGET_CATS.forEach(([cat,key])=>{
const defs = REGISTRY.filter(d=>d.cat===cat);
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.innerHTML = `<div class="gi">${def.icon}</div><div class="gn">${T("wg_"+def.id)}</div><div class="gd">${T("wg_"+def.id+"_d")}</div>`;
c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); });
grid.appendChild(c);
});
gallery.appendChild(grid);
});
}
galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); });
/* ---------- 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");
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;
fr.appendChild(nm);
const add = document.createElement("button");
add.textContent = T("newBoard");
add.addEventListener("click", ()=>{
fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`));
switchTo(fi, fo.boards.length-1);
renderBoardsPanel();
});
fr.appendChild(add);
if(BS.folders.length > 1){
const del = document.createElement("button");
del.textContent = "🗑";
del.addEventListener("click", ()=>{
if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; }
markBoardChange();
renderBoardsPanel();
});
fr.appendChild(del);
}
boardsPanel.appendChild(fr);
const row = document.createElement("div");
row.className = "bp-boards";
fo.boards.forEach((bo, bi)=>{
const chip = document.createElement("div");
chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : "");
const lbl = document.createElement("span");
lbl.textContent = bo.name;
chip.appendChild(lbl);
chip.addEventListener("click", e=>{
if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return;
if(fi===BS.f && bi===BS.b) return;
switchTo(fi, bi);
renderBoardsPanel();
});
chip.addEventListener("dblclick", ()=>{
if(chip.querySelector("input")) return;
const inp = document.createElement("input");
inp.value = bo.name; inp.maxLength = 20;
chip.replaceChild(inp, lbl);
inp.focus(); inp.select();
let done = false;
const commit = ()=>{
if(done) return; done = true;
bo.name = inp.value.trim() || bo.name;
markBoardChange();
renderBoardsPanel(); updateBoardsUI();
};
inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); });
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
});
if(fo.boards.length > 1 || BS.folders.length > 1){
const x = document.createElement("button");
x.className = "bpb-x"; x.textContent = "✕";
x.addEventListener("click", ev=>{
ev.stopPropagation();
const wasCur = (fi===BS.f && bi===BS.b);
fo.boards.splice(bi,1);
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(wasCur){ BS.b = 0; loadCurrentSlot(); }
else if(fi===BS.f && bi < BS.b) BS.b--;
markBoardChange();
renderBoardsPanel();
});
chip.appendChild(x);
}
row.appendChild(chip);
});
boardsPanel.appendChild(row);
});
const nf = document.createElement("div");
nf.className = "bp-new";
const inp = document.createElement("input");
inp.placeholder = T("folderPh"); inp.maxLength = 20;
const addFolder = ()=>{
const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`;
BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] });
inp.value = "";
markBoardChange();
renderBoardsPanel();
};
inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); });
const nb = document.createElement("button");
nb.className = "tbtn"; nb.textContent = T("newFolder");
nb.addEventListener("click", addFolder);
nf.appendChild(inp); nf.appendChild(nb);
boardsPanel.appendChild(nf);
const hint = document.createElement("div");
hint.className = "bp-hint"; hint.textContent = T("renameTip");
boardsPanel.appendChild(hint);
}
document.addEventListener("langchange", updateBoardsUI);