All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
1064 lines
45 KiB
JavaScript
1064 lines
45 KiB
JavaScript
/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */
|
||
/* =========================================================
|
||
Widget framework
|
||
==========================================================*/
|
||
const board = document.getElementById("board");
|
||
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"]];
|
||
|
||
function makeWidget(def, saved){
|
||
const win = document.createElement("div");
|
||
win.className = "widget";
|
||
win.style.width = (saved && saved.w ? saved.w : def.w)+"px";
|
||
win.style.height = (saved && saved.h ? saved.h : def.h)+"px";
|
||
if(def.minW) win.style.minWidth = def.minW+"px";
|
||
if(def.minH) win.style.minHeight = def.minH+"px";
|
||
win.style.left = (saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240) + "px";
|
||
win.style.top = (saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160) + "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);
|
||
|
||
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));
|
||
/* 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();
|
||
});
|
||
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"));
|
||
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); };
|
||
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", ()=>{
|
||
if(pill){ pill.remove(); pill = null; }
|
||
instances.delete(inst); win.remove(); updateEmptyHint();
|
||
});
|
||
updateEmptyHint();
|
||
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
|
||
}))
|
||
};
|
||
}
|
||
function restoreBoard(b){
|
||
[...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();
|
||
}
|
||
|
||
/* ---------- 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 BOARD = { bg:"blank" };
|
||
const penbar = document.getElementById("penbar");
|
||
function setPen(on){
|
||
document.body.classList.toggle("pen", on);
|
||
document.body.classList.toggle("pen-text", on && PEN.tool==="text");
|
||
refreshPenbar();
|
||
}
|
||
function setBoardBg(bg){
|
||
BOARD.bg = bg;
|
||
board.className = (bg && bg!=="blank") ? "bg-"+bg : "";
|
||
refreshPenbar();
|
||
}
|
||
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); refreshPenbar(); });
|
||
penbar.querySelector(".pen-tools").appendChild(b);
|
||
});
|
||
["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"].forEach(c=>{
|
||
const b = document.createElement("button");
|
||
b.className = "wp-color"; b.dataset.c = c;
|
||
const dot = document.createElement("i"); dot.style.background = c;
|
||
b.appendChild(dot);
|
||
b.addEventListener("click", ()=>{ PEN.color = c; if(PEN.tool==="erase") PEN.tool = "pen"; refreshPenbar(); });
|
||
penbar.querySelector(".pen-colors").appendChild(b);
|
||
});
|
||
[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; 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));
|
||
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));
|
||
penbar.querySelector(".pen-extras").appendChild(b);
|
||
});
|
||
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(){
|
||
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 = ()=>{
|
||
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());
|
||
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); };
|
||
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); };
|
||
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||
});
|
||
board.appendChild(d);
|
||
return d;
|
||
}
|
||
document.addEventListener("langchange", refreshFab);
|
||
|
||
/* =========================================================
|
||
Multiple boards, organised in folders (quick nav next to +)
|
||
==========================================================*/
|
||
function freshBS(){
|
||
return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1",
|
||
boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }],
|
||
f:0, b:0 };
|
||
}
|
||
let BS = freshBS();
|
||
function boardsFromData(data){
|
||
if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards;
|
||
if(data && data.board){
|
||
const bs = freshBS();
|
||
bs.folders[0].boards[0].data = data.board; /* migrate the old single board */
|
||
return bs;
|
||
}
|
||
return freshBS();
|
||
}
|
||
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();
|
||
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({ name:T("boardName")+" 1", data:null });
|
||
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
|
||
else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
|
||
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({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null });
|
||
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({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null });
|
||
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--; }
|
||
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;
|
||
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({ name:T("boardName")+" 1", data:null });
|
||
if(wasCur){ BS.b = 0; loadCurrentSlot(); }
|
||
else if(fi===BS.f && bi < BS.b) BS.b--;
|
||
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:[{ name:T("boardName")+" 1", data:null }] });
|
||
inp.value = "";
|
||
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);
|
||
|