All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 44s
1791 lines
85 KiB
JavaScript
1791 lines
85 KiB
JavaScript
/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */
|
||
/* =========================================================
|
||
Widget framework
|
||
==========================================================*/
|
||
const board = document.getElementById("board");
|
||
/* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */
|
||
let BOARD_HISTORY=[], BOARD_FUTURE=[], HISTORY_TIMER=null, HISTORY_BUSY=false;
|
||
function recordBoardHistory(){
|
||
if(RESTORING||HISTORY_BUSY) return; clearTimeout(HISTORY_TIMER); HISTORY_TIMER=setTimeout(()=>{
|
||
const snap=serializeBoard(), sig=JSON.stringify(snap);
|
||
if(!BOARD_HISTORY.length||JSON.stringify(BOARD_HISTORY.at(-1))!==sig){BOARD_HISTORY.push(snap);if(BOARD_HISTORY.length>30)BOARD_HISTORY.shift();BOARD_FUTURE=[];}
|
||
},250);
|
||
}
|
||
const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave("boards"); recordBoardHistory(); } };
|
||
/* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen,
|
||
schrijfblad, spelvoortgang): elke interactie in een widget-body plant een
|
||
(debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk
|
||
te instrumenteren */
|
||
document.addEventListener("input", e=>{
|
||
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
|
||
}, true);
|
||
document.addEventListener("pointerup", e=>{
|
||
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
|
||
}, true);
|
||
const dock = document.getElementById("dock");
|
||
let zTop = 10, widgetCount = 0;
|
||
const instances = new Set();
|
||
|
||
const REGISTRY = [
|
||
/* het ankerwidget staat bewust vooraan: het woordweb voor woordenschat-
|
||
onderwijs is de kern van de taalkant en verdient de prominentste plek */
|
||
{ id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor },
|
||
{ 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:"sentence", cat:"taal", icon:"🧩", w:620, h:520, minW:400, minH:360, mount:mountSentence },
|
||
{ id:"madd", cat:"rekenen", icon:"➕", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"add",o) },
|
||
{ id:"msub", cat:"rekenen", icon:"➖", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"sub",o) },
|
||
{ id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"mul",o) },
|
||
{ id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"div",o) },
|
||
{ id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s,o)=>mountMath(r,s,"pct",o) },
|
||
{ id:"numberline", cat:"rekenen", icon:"↔️", w:660, h:430, minW:420, minH:320, mount:mountNumberline },
|
||
{ id:"fractions", cat:"rekenen", icon:"🍫", w:600, h:480, minW:380, minH:320, mount:mountFractions },
|
||
{ id:"clock", cat:"rekenen", icon:"🕙", w:500, h:500, minW:340, minH:340, mount:mountClock },
|
||
{ id:"placevalue", cat:"rekenen", icon:"🔢", w:610, h:390, minW:390, minH:280, mount:mountPlacevalue },
|
||
{ id:"money", cat:"rekenen", icon:"🪙", w:580, h:560, minW:380, minH:360, mount:mountMoney },
|
||
{ id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes },
|
||
{ id:"trees", cat:"world", icon:"🌳", w:620, h:600, minW:360, minH:400, mount:mountTrees },
|
||
{ id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds },
|
||
{ id:"plants", cat:"world", icon:"🌼", w:620, h:600, minW:360, minH:400, mount:mountPlants },
|
||
{ id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography },
|
||
{ id:"coloring", cat:"creatief", icon:"🎨", w:760, h:700, minW:390, minH:430, mount:mountColoring },
|
||
{ id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite },
|
||
{ id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind },
|
||
{ id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes },
|
||
{ id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer },
|
||
{ id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice },
|
||
{ id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames },
|
||
{ id:"birthday",cat:"class", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday },
|
||
{ id:"schedule", cat:"class", icon:"📅", w:500, h:520, minW:340, minH:340, mount:mountSchedule },
|
||
{ id:"light", cat:"class", icon:"🚦", w:480, h:440, minW:320, minH:320, mount:mountLight },
|
||
{ id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood },
|
||
{ id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll },
|
||
{ id:"noise", cat:"class", icon:"🎙️", w:420, h:600, minW:320, minH:460, mount:mountNoise },
|
||
{ id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings },
|
||
{ id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe },
|
||
{ id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments },
|
||
{ id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget },
|
||
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
|
||
];
|
||
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["creatief","catCreative"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
|
||
|
||
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een
|
||
telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv.
|
||
640px mag daar niet alsnog horizontale overflow afdwingen. */
|
||
function widgetSizeBounds(def, left=0, top=0){
|
||
const maxW = Math.max(1, board.clientWidth - left - 8);
|
||
const maxH = Math.max(1, board.clientHeight - top - 8);
|
||
return {
|
||
minW: Math.min(def.minW || 240, maxW),
|
||
minH: Math.min(def.minH || 180, maxH),
|
||
maxW, maxH,
|
||
};
|
||
}
|
||
function clampWidgetSize(w, h, def){
|
||
const {minW,minH,maxW,maxH} = widgetSizeBounds(def);
|
||
return {
|
||
w: Math.max(minW, Math.min(w, maxW)),
|
||
h: Math.max(minH, Math.min(h, maxH)),
|
||
};
|
||
}
|
||
|
||
/* stabiel widget-instantie-id, zelfde patroon als genBoardId() verderop - nodig
|
||
zodat een toewijzing van één enkele widget (zie pupil.js/admin.js) die widget
|
||
blijft vinden ook als er widgets vóór/na worden toegevoegd of verplaatst */
|
||
function genWidgetId(){ return "w" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); }
|
||
|
||
function curriculumGoalsForWidget(widgetId){
|
||
const matches=[];
|
||
CURRICULUM_PO_2026.areas.forEach(area=>area.goals.forEach(goal=>{
|
||
if(goal.widgets.includes(widgetId))matches.push({area,goal});
|
||
}));
|
||
return matches;
|
||
}
|
||
let widgetCurriculumWrap=null;
|
||
function closeWidgetCurriculumInfo(){
|
||
if(widgetCurriculumWrap)widgetCurriculumWrap.classList.remove("open");
|
||
}
|
||
function openWidgetCurriculumInfo(def){
|
||
if(!currentUser||!can("curriculum.view"))return;
|
||
if(!widgetCurriculumWrap){
|
||
widgetCurriculumWrap=document.createElement("div");
|
||
widgetCurriculumWrap.className="modalwrap";
|
||
widgetCurriculumWrap.id="widgetCurriculumWrap";
|
||
widgetCurriculumWrap.setAttribute("role","dialog");
|
||
widgetCurriculumWrap.setAttribute("aria-modal","true");
|
||
widgetCurriculumWrap.addEventListener("click",event=>{
|
||
if(event.target===widgetCurriculumWrap)closeWidgetCurriculumInfo();
|
||
});
|
||
document.body.appendChild(widgetCurriculumWrap);
|
||
}
|
||
const panel=document.createElement("div");
|
||
panel.className="modal widget-curriculum-modal";
|
||
const head=document.createElement("div");
|
||
head.className="widget-curriculum-head";
|
||
const title=document.createElement("h2");
|
||
title.id="widgetCurriculumTitle";
|
||
title.textContent=def.icon+" "+T("wg_"+def.id);
|
||
const close=document.createElement("button");
|
||
close.type="button";close.className="wclose";close.textContent="✕";
|
||
close.title=T("close");close.setAttribute("aria-label",T("close"));
|
||
close.addEventListener("click",closeWidgetCurriculumInfo);
|
||
head.append(title,close);panel.appendChild(head);
|
||
panel.appendChild(document.createTextNode(T("widgetCurriculumIntro")));
|
||
const goals=curriculumGoalsForWidget(def.id);
|
||
if(goals.length){
|
||
const list=document.createElement("div");
|
||
list.className="widget-curriculum-goals";
|
||
goals.forEach(({area,goal})=>{
|
||
const link=document.createElement("a");
|
||
link.className="widget-curriculum-goal";
|
||
link.href="#wiki-goal-"+goal.id;
|
||
const label=document.createElement("strong");
|
||
label.textContent=area.icon+" "+T("wikiGoal")+" "+goal.id+" · "+area.title;
|
||
const summary=document.createElement("span");
|
||
summary.textContent=goal.summary;
|
||
const status=document.createElement("small");
|
||
status.textContent=T(goal.coverage==="partial"?"wikiCoveragePartial":goal.coverage==="support"?"wikiCoverageSupport":"wikiCoverageOpen")+" · "+T("widgetCurriculumWiki");
|
||
link.append(label,summary,status);
|
||
link.addEventListener("click",event=>{
|
||
event.preventDefault();closeWidgetCurriculumInfo();
|
||
openSettings("curriculum",{area:area.id,goal:goal.id});
|
||
});
|
||
list.appendChild(link);
|
||
});
|
||
panel.appendChild(list);
|
||
}else{
|
||
const empty=document.createElement("p");
|
||
empty.className="widget-curriculum-empty";
|
||
empty.textContent=T("widgetCurriculumNone");
|
||
panel.appendChild(empty);
|
||
}
|
||
const careful=document.createElement("p");
|
||
careful.className="widget-curriculum-careful";
|
||
careful.textContent=T("widgetCurriculumCareful");
|
||
panel.appendChild(careful);
|
||
const all=document.createElement("a");
|
||
all.className="tbtn widget-curriculum-all";
|
||
all.href="#curriculum";
|
||
all.textContent=T("widgetCurriculumAll");
|
||
all.addEventListener("click",event=>{
|
||
event.preventDefault();closeWidgetCurriculumInfo();openSettings("curriculum");
|
||
});
|
||
panel.appendChild(all);
|
||
widgetCurriculumWrap.setAttribute("aria-labelledby",title.id);
|
||
widgetCurriculumWrap.replaceChildren(panel);
|
||
widgetCurriculumWrap.classList.add("open");
|
||
close.focus();
|
||
}
|
||
document.addEventListener("userchange",()=>{
|
||
if(!currentUser||!can("curriculum.view"))closeWidgetCurriculumInfo();
|
||
});
|
||
|
||
function makeWidget(def, saved){
|
||
const win = document.createElement("div");
|
||
win.className = "widget";
|
||
win.dataset.widget = def.id;
|
||
const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def);
|
||
const bounds = widgetSizeBounds(def);
|
||
win.style.width = size.w+"px";
|
||
win.style.height = size.h+"px";
|
||
win.style.minWidth = bounds.minW+"px";
|
||
win.style.minHeight = bounds.minH+"px";
|
||
const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240;
|
||
const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160;
|
||
win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px";
|
||
win.style.top = Math.max(0, Math.min(cy, board.clientHeight - size.h - 8)) + "px";
|
||
win.style.zIndex = ++zTop;
|
||
widgetCount++;
|
||
if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; }
|
||
if(saved && saved.locked){ win.dataset.locked="1"; }
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "widget-head";
|
||
head.innerHTML = `<span class="wicon">${def.icon}</span><span class="wtitle"></span>`;
|
||
const infoBtn = document.createElement("button");
|
||
infoBtn.className="wmin winfo";infoBtn.type="button";infoBtn.textContent="ⓘ";infoBtn.hidden=true;
|
||
infoBtn.addEventListener("click",event=>{event.stopPropagation();openWidgetCurriculumInfo(def);});
|
||
head.appendChild(infoBtn);
|
||
const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn);
|
||
const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn);
|
||
const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn);
|
||
const zBtn = document.createElement("button");
|
||
zBtn.className = "wmin wz"; zBtn.textContent = "A+";
|
||
head.appendChild(zBtn);
|
||
const minBtn = document.createElement("button");
|
||
minBtn.className = "wmin"; minBtn.textContent = "–";
|
||
head.appendChild(minBtn);
|
||
const maxBtn = document.createElement("button");
|
||
maxBtn.className = "wmin"; maxBtn.textContent = "▢";
|
||
head.appendChild(maxBtn);
|
||
const close = document.createElement("button");
|
||
close.className = "wclose"; close.textContent = "✕";
|
||
head.appendChild(close);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "widget-body";
|
||
win.appendChild(head); win.appendChild(body);
|
||
|
||
/* eigen resize-greep (touch-vriendelijk, vervangt de native browser-grip) */
|
||
const rs = document.createElement("div");
|
||
rs.className = "widget-rs";
|
||
rs.addEventListener("pointerdown", e=>{
|
||
if(win.dataset.locked==="1") return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
win.style.zIndex = ++zTop;
|
||
const zf = VZ();
|
||
const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY;
|
||
const mv = ev=>{
|
||
const {minW,minH,maxW,maxH} = widgetSizeBounds(def, win.offsetLeft, win.offsetTop);
|
||
let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf;
|
||
nw = Math.max(minW, Math.min(nw, maxW));
|
||
nh = Math.max(minH, Math.min(nh, maxH));
|
||
win.style.width = nw+"px"; win.style.height = nh+"px";
|
||
};
|
||
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
|
||
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||
});
|
||
win.appendChild(rs);
|
||
const applyLock=()=>{const locked=win.dataset.locked==="1";win.classList.toggle("locked",locked);lockBtn.textContent=locked?"🔒":"🔓";lockBtn.title=T(locked?"widgetUnlock":"widgetLock");lockBtn.setAttribute("aria-label",lockBtn.title);lockBtn.setAttribute("aria-pressed",String(locked))};
|
||
lockBtn.addEventListener("click",()=>{win.dataset.locked=win.dataset.locked==="1"?"":"1";applyLock();markBoardChange()});
|
||
previewBtn.addEventListener("click",()=>{win.classList.toggle("preview");previewBtn.classList.toggle("on",win.classList.contains("preview"));previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-pressed",String(win.classList.contains("preview"))) });
|
||
applyLock();
|
||
|
||
let pill = null;
|
||
function setMin(m){
|
||
if(m && !pill){
|
||
win.dataset.prevW = win.offsetWidth;
|
||
win.dataset.prevH = win.offsetHeight;
|
||
win.dataset.min = "1";
|
||
win.style.display = "none";
|
||
pill = document.createElement("button");
|
||
pill.className = "dock-item";
|
||
pill.innerHTML = `<span class="di">${def.icon}</span><span class="dl"></span><span class="dock-close" role="button">✕</span>`;
|
||
pill.querySelector(".dl").textContent = T("wg_"+def.id);
|
||
pill.title = T("wMax");
|
||
/* ook een geminimaliseerde widget is direct te sluiten, zonder hem
|
||
eerst terug op het bord te hoeven zetten */
|
||
const x = pill.querySelector(".dock-close");
|
||
x.title = T("close");
|
||
x.addEventListener("click", e=>{ e.stopPropagation(); removeWidget(); });
|
||
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;
|
||
fitWidgetsToBoard();
|
||
}
|
||
}
|
||
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 = "▢";
|
||
}
|
||
if(!m) fitWidgetsToBoard();
|
||
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.closest("button")) 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");
|
||
infoBtn.title = T("widgetCurriculumInfo");
|
||
infoBtn.setAttribute("aria-label",infoBtn.title);
|
||
if(pill){
|
||
pill.querySelector(".dl").textContent = T("wg_"+def.id);
|
||
pill.title = T("wMax");
|
||
pill.querySelector(".dock-close").title = T("close");
|
||
}
|
||
};
|
||
const setCurriculumInfoVisibility=()=>{infoBtn.hidden=!(currentUser&&can("curriculum.view"));};
|
||
setTitle();setCurriculumInfoVisibility();
|
||
document.addEventListener("langchange", setTitle);
|
||
document.addEventListener("userchange",setCurriculumInfoVisibility);
|
||
|
||
win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; });
|
||
head.addEventListener("pointerdown", e=>{
|
||
if(win.dataset.locked==="1" || e.target.closest("button")) return;
|
||
e.preventDefault();
|
||
const zf = VZ();
|
||
const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop;
|
||
const move = ev=>{
|
||
win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px";
|
||
win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px";
|
||
};
|
||
const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); markBoardChange(); };
|
||
document.addEventListener("pointermove",move); document.addEventListener("pointerup",up);
|
||
});
|
||
|
||
board.appendChild(win);
|
||
/* Op het eigen bord van een thuiskind is de centrale leerjaarinstelling
|
||
leidend. Leerkracht en systeemmanager krijgen hieronder een opgeslagen
|
||
keuze per widget; overige borden behouden hun bestaande widgetstate. */
|
||
const initialState = typeof learningStateForWidget === "function"
|
||
? learningStateForWidget(def, saved ? saved.state : null)
|
||
: (saved ? saved.state : null);
|
||
let boardLearning = typeof boardLearningSelection === "function"
|
||
? boardLearningSelection(def, initialState) : null;
|
||
let mountedApi = {};
|
||
const mountContent = state=>{
|
||
body.replaceChildren();
|
||
const mountedState = typeof boardLearningMountState === "function"
|
||
? boardLearningMountState(def, state, boardLearning) : state;
|
||
mountedApi = def.mount(body, mountedState) || {};
|
||
if(typeof appendLearningProfileBadge === "function" && mountedState &&
|
||
["taal","rekenen","creatief"].includes(def.cat)){
|
||
appendLearningProfileBadge(body, def.cat, mountedState, def.id);
|
||
}
|
||
if(boardLearning && typeof appendBoardLearningLevelControls === "function"){
|
||
appendBoardLearningLevelControls(body,def,boardLearning,next=>{
|
||
const live=mountedApi.getState?mountedApi.getState():{};
|
||
boardLearning=next;
|
||
mountContent(Object.assign({},live,next,{managedLearningLevel:false}));
|
||
markBoardChange();
|
||
});
|
||
}
|
||
};
|
||
mountContent(initialState);
|
||
/* Het widget-API blijft stabiel terwijl een niveauwissel de inhoud opnieuw
|
||
mount. De auteurskeuze wordt hier terug aan de widgetstate toegevoegd;
|
||
widgetmodules laten leerlinggebonden velden zelf bewust buiten getState. */
|
||
const api = {
|
||
getState:()=>{
|
||
const state=mountedApi.getState?mountedApi.getState():null;
|
||
return boardLearning?Object.assign({},state||{},boardLearning,{managedLearningLevel:false}):state
|
||
},
|
||
setState:state=>{mountContent(state)}
|
||
};
|
||
if(saved && saved.min) setMin(true);
|
||
if(saved && saved.max) setMax(true);
|
||
const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() };
|
||
dupBtn.title=T("widgetDuplicate");dupBtn.setAttribute("aria-label",dupBtn.title);previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-label",previewBtn.title);
|
||
dupBtn.addEventListener("click",()=>makeWidget(def,{x:win.offsetLeft+28,y:win.offsetTop+28,w:win.offsetWidth,h:win.offsetHeight,wz:+win.dataset.wz||1,state:api.getState?structuredClone(api.getState()):null}));
|
||
instances.add(inst);
|
||
/* gedeeld sluitpad voor de ✕ in de widgetkop én de ✕ op de dock-pil */
|
||
function removeWidget(){
|
||
markBoardChange();
|
||
if(pill){ pill.remove(); pill = null; }
|
||
document.removeEventListener("langchange",setTitle);
|
||
document.removeEventListener("userchange",setCurriculumInfoVisibility);
|
||
instances.delete(inst); win.remove(); updateEmptyHint();
|
||
}
|
||
close.addEventListener("click", removeWidget);
|
||
updateEmptyHint();
|
||
markBoardChange();
|
||
return inst;
|
||
}
|
||
|
||
/* Ook reeds geopende widgets blijven passen wanneer een telefoon draait, het
|
||
browservenster smaller wordt of een zijbalk de bordruimte verandert. */
|
||
function fitWidgetsToBoard(){
|
||
instances.forEach(inst=>{
|
||
const {win,def} = inst;
|
||
const bounds = widgetSizeBounds(def);
|
||
win.style.minWidth = bounds.minW+"px";
|
||
win.style.minHeight = bounds.minH+"px";
|
||
if(win.dataset.min==="1") return;
|
||
if(win.dataset.max==="1"){
|
||
win.style.left = "0px"; win.style.top = "0px";
|
||
win.style.width = board.clientWidth+"px";
|
||
win.style.height = board.clientHeight+"px";
|
||
return;
|
||
}
|
||
const width = Math.max(bounds.minW, Math.min(win.offsetWidth, bounds.maxW));
|
||
const height = Math.max(bounds.minH, Math.min(win.offsetHeight, bounds.maxH));
|
||
win.style.width = width+"px"; win.style.height = height+"px";
|
||
win.style.left = Math.max(0, Math.min(win.offsetLeft, board.clientWidth-width-8))+"px";
|
||
win.style.top = Math.max(0, Math.min(win.offsetTop, board.clientHeight-height-8))+"px";
|
||
});
|
||
}
|
||
new ResizeObserver(fitWidgetsToBoard).observe(board);
|
||
|
||
function updateEmptyHint(){
|
||
document.body.classList.toggle("board-empty", !board.querySelector(".widget"));
|
||
}
|
||
|
||
function serializeBoard(){
|
||
return {
|
||
ink: boardInkURL(),
|
||
bg: BOARD.bg,
|
||
widgets: [...instances].map(inst=>{
|
||
const w = inst.win;
|
||
const min = w.dataset.min === "1";
|
||
const max = w.dataset.max === "1";
|
||
return {
|
||
id: inst.def.id,
|
||
wid: inst.wid,
|
||
x: max ? +w.dataset.pmx : w.offsetLeft,
|
||
y: max ? +w.dataset.pmy : w.offsetTop,
|
||
w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth,
|
||
h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight,
|
||
min, max,
|
||
wz: +w.dataset.wz || 1,
|
||
bare: w.dataset.bare === "1",
|
||
locked: w.dataset.locked === "1",
|
||
state: inst.api.getState ? inst.api.getState() : null
|
||
};
|
||
}),
|
||
imgs: [...board.querySelectorAll(".bimg")].map(b=>({
|
||
src: b.querySelector("img").src,
|
||
x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth
|
||
}))
|
||
};
|
||
}
|
||
/* tijdens het terugzetten van een bord geen autosaves plannen: het herstellen
|
||
zelf is geen wijziging (en zou anders direct na inloggen al een save triggeren) */
|
||
let RESTORING = false;
|
||
function restoreBoard(b){
|
||
RESTORING = true;
|
||
[...instances].forEach(inst=>inst.win.remove());
|
||
instances.clear();
|
||
dock.innerHTML = "";
|
||
widgetCount = 0;
|
||
(b.widgets||[]).forEach(wd=>{
|
||
const def = REGISTRY.find(d=>d.id===wd.id);
|
||
if(def) makeWidget(def, wd);
|
||
});
|
||
setBoardInk(b.ink);
|
||
setBoardBg(b.bg || "dots");
|
||
board.querySelectorAll(".bimg").forEach(x=>x.remove());
|
||
(b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w));
|
||
updateEmptyHint();
|
||
RESTORING = false;
|
||
if(!HISTORY_BUSY){BOARD_HISTORY=[serializeBoard()];BOARD_FUTURE=[];}
|
||
}
|
||
|
||
/* ---------- board drawing layer (pen mode) ---------- */
|
||
const bInk = document.createElement("canvas");
|
||
bInk.id = "boardInk";
|
||
board.insertBefore(bInk, board.firstChild);
|
||
const bCtx = bInk.getContext("2d");
|
||
/* aparte laag voor de markeerstift: dáár tekent hij dekkend, de laag zelf is
|
||
doorschijnend (CSS), en pas bij het loslaten wordt de hele haal in één keer
|
||
met 32% op de inktlaag gezet. Zo stapelen overlappende lijnstukjes binnen
|
||
één haal niet meer tot donkere vlekken op elke overgang. */
|
||
const bMark = document.createElement("canvas");
|
||
bMark.id = "boardMark";
|
||
board.insertBefore(bMark, bInk.nextSibling);
|
||
const bMarkCtx = bMark.getContext("2d");
|
||
let bDpr = window.devicePixelRatio || 1;
|
||
function bResize(){
|
||
const w = board.clientWidth, h = board.clientHeight;
|
||
if(w<2 || h<2) return;
|
||
bDpr = window.devicePixelRatio || 1; /* vers: verhuizen naar ander scherm/zoom */
|
||
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";
|
||
bMark.width = bInk.width; bMark.height = bInk.height;
|
||
bMarkCtx.lineCap = "round"; bMarkCtx.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();
|
||
[1,2,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;
|
||
/* onbekende (oude/corrupte) bg-waarde mag nooit de hele hydrate laten
|
||
crashen - dat logde de gebruiker stilletjes uit bij het verversen */
|
||
penbar.querySelector('[data-picker="backgrounds"] .pen-current').title = T((PEN_BGS.find(x=>x[0]===BOARD.bg) || PEN_BGS[0])[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();
|
||
/* pen actief en neergezet op de randzone van de lat: tekenen gaat vóór
|
||
verslepen - zoals je een echte liniaal vasthoudt terwijl je erlangs
|
||
tekent. Alleen een greep verder van de rand verplaatst het gereedschap. */
|
||
if(!e.target.classList.contains("dtool-rot")
|
||
&& document.body.classList.contains("pen")
|
||
&& ["pen","vulp","mark"].includes(PEN.tool)
|
||
&& (t.classList.contains("dtool-ruler") || t.classList.contains("dtool-geo"))){
|
||
const r = bInk.getBoundingClientRect();
|
||
const px = (e.clientX - r.left) * (board.clientWidth / r.width);
|
||
const py = (e.clientY - r.top) * (board.clientHeight / r.height);
|
||
const hit = nearestEdge(px, py);
|
||
if(hit && hit.d <= 24){ startInkStroke(e, true); return; }
|
||
}
|
||
/* vastgezet gereedschap verschuift en draait niet meer (slot-knop) */
|
||
if(t._locked) return;
|
||
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;
|
||
/* klikt vast op veelvouden van 45° (waterpas/haaks in één beweging)
|
||
en toont tijdens het draaien de hoek in graden */
|
||
const badge = t.querySelector(".dtool-deg") || t.appendChild(Object.assign(document.createElement("div"), { className:"dtool-deg" }));
|
||
badge.style.display = "block";
|
||
const mv = ev=>{
|
||
let a = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start;
|
||
const near = Math.round(a/45)*45;
|
||
if(Math.abs(a-near) < 4) a = near;
|
||
ang = a;
|
||
t._ang = ang; /* voor het kleven van de pen aan de gedraaide rand */
|
||
t.style.transform = `rotate(${ang}deg)`;
|
||
const shown = Math.round(((ang % 360) + 360) % 360);
|
||
badge.textContent = shown + "°";
|
||
badge.style.transform = `rotate(${-ang}deg)`; /* leesbaar houden */
|
||
badge.classList.toggle("snapped", Math.abs(((ang % 45) + 45) % 45) < 0.01);
|
||
};
|
||
const up = ()=>{
|
||
badge.style.display = "none";
|
||
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"){
|
||
/* 50px = 1 cm; logische lengtes in hele centimeters, schaal begint bij 0
|
||
met een kleine kantlijn zoals op een echte liniaal */
|
||
const RULER_CMS = [5, 10, 15, 20];
|
||
t._cm = 10; t._hc = false;
|
||
const drawRuler = ()=>{
|
||
const cmLen = Math.min(t._cm, Math.max(5, Math.floor((board.clientWidth-60)/50)));
|
||
const pad = 18, scale = cmLen*50, L = scale + pad*2, 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=0; i<=scale; i+=5){
|
||
const cm = i%50===0, half = i%25===0;
|
||
const len = cm ? 22 : half ? 14 : 8;
|
||
ticks += `<line x1="${pad+i}" y1="2" x2="${pad+i}" y2="${2+len}" stroke="${ink}" stroke-width="${cm ? (hc?2.5:1.6) : (hc?1.6:1)}"/>`;
|
||
if(cm) ticks += `<text x="${pad+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="68" font-size="12" fill="${ink}" text-anchor="middle" opacity=".65">${cmLen} cm</text>
|
||
${ticks}</svg>`;
|
||
/* tekenranden voor het kleven van de pen (bord-lokale coördinaten) */
|
||
t._edges = [[[0,0],[L,0]], [[0,76],[L,76]]];
|
||
const lenBtn = t.querySelector(".do-len");
|
||
lenBtn.textContent = cmLen + " cm";
|
||
lenBtn.title = T("dtLen");
|
||
};
|
||
t.innerHTML = `<div class="dtool-svg"></div>
|
||
<div class="dtool-opts">
|
||
<button class="do-len do-wide"></button>
|
||
<button class="do-hc">◐</button>
|
||
<button class="do-lock">🔓</button>
|
||
</div>`;
|
||
t.querySelector(".do-len").addEventListener("click", ()=>{
|
||
t._cm = RULER_CMS[(RULER_CMS.indexOf(t._cm)+1) % RULER_CMS.length]; drawRuler();
|
||
});
|
||
const hcBtn = t.querySelector(".do-hc");
|
||
hcBtn.title = T("dtContrast");
|
||
hcBtn.addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); });
|
||
t.style.height = "76px";
|
||
drawRuler();
|
||
}else if(kind==="geo"){
|
||
/* geodriehoek zoals de echte: cm-schaal op de hypotenusa met 0 in het
|
||
midden, gradenbogen in twee richtingen, en twee niveaus:
|
||
basis - grote cijfers, alleen de hoofdhoeken (30/45/60/90/…) en
|
||
hele centimeters, voor jonge leerlingen;
|
||
volledig - mm-streepjes, graden per 2° met dubbele schaal (links- én
|
||
rechtsom, zoals op een echte geodriehoek) en evenwijdige
|
||
hulplijnen per cm om parallelle lijnen te tekenen. */
|
||
const GEO_CMS = [6, 8, 12];
|
||
t._cm = 8; t._hc = false; t._full = false;
|
||
const drawGeo = ()=>{
|
||
const maxCm = Math.max(6, Math.floor((board.clientWidth-40)/50));
|
||
const cmLen = Math.min(t._cm, maxCm);
|
||
const W = cmLen*50 + 28, H = W/2 + 22, hc = t._hc, full = t._full;
|
||
const ink = hc ? "#000" : "#1e4f74";
|
||
const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.55)";
|
||
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 + tekenrand */
|
||
const half = W/2 - 14;
|
||
/* cm-schaal op de tekenrand, 0 in het midden; volledig ook mm */
|
||
let cmTicks = `<text x="${ax}" y="${by-18}" font-size="${hc?15:12}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">0</text>`;
|
||
const stepPx = full ? 5 : 25;
|
||
for(let i=stepPx; i<=half; i+=stepPx){
|
||
const cm = i%50===0, halfCm = i%25===0;
|
||
const len = cm ? 13 : halfCm ? 9 : 5;
|
||
[ax-i, ax+i].forEach(x=>{
|
||
cmTicks += `<line x1="${x}" y1="${by}" x2="${x}" y2="${by-len}" stroke="${ink}" stroke-width="${cm?(hc?2.2:1.4):1}"/>`;
|
||
});
|
||
if(cm && i<=half-14){
|
||
[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>`;
|
||
});
|
||
}
|
||
}
|
||
/* gradenboog: basis = hoofdhoeken groot; volledig = per 2° met dubbele
|
||
schaal (buiten linksom, binnen rechtsom) zoals op een echte geodriehoek */
|
||
let degs = "";
|
||
const R = half*0.62;
|
||
const degStep = full ? 2 : 10;
|
||
for(let d=degStep; d<180; d+=degStep){
|
||
const major = full ? d%10===0 : d%30===0 || d===45 || d===135;
|
||
const rad = d*Math.PI/180;
|
||
const x1 = ax - Math.cos(rad)*(R-(major?10:5)), y1 = by - Math.sin(rad)*(R-(major?10:5));
|
||
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="${major?(hc?2:1.4):0.7}"/>`;
|
||
}
|
||
/* 10° en 170° liggen vrijwel op de baseline en zouden de cm-cijfers
|
||
overlappen - net als op een echte geodriehoek beginnen de labels
|
||
daar pas een stap hoger (de streepjes staan er wel) */
|
||
const labels = full
|
||
? Array.from({length:15}, (_,i)=>(i+2)*10)
|
||
: [30,45,60,90,120,135,150];
|
||
labels.forEach(d=>{
|
||
const rad = d*Math.PI/180;
|
||
if(full){
|
||
const xo = ax - Math.cos(rad)*(R-20), yo = by - Math.sin(rad)*(R-20);
|
||
const xi = ax - Math.cos(rad)*(R-36), yi = by - Math.sin(rad)*(R-36);
|
||
degs += `<text x="${xo}" y="${yo+3.5}" font-size="10.5" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${d}</text>`;
|
||
degs += `<text x="${xi}" y="${yi+3.5}" font-size="9" fill="${ink}" ${halo} text-anchor="middle" opacity=".75">${180-d}</text>`;
|
||
}else{
|
||
const xt = ax - Math.cos(rad)*(R-24), yt = by - Math.sin(rad)*(R-24);
|
||
degs += `<text x="${xt}" y="${yt+5}" font-size="${hc?16:14}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${d}°</text>`;
|
||
}
|
||
});
|
||
/* volledig: evenwijdige hulplijnen per cm, parallel aan de tekenrand */
|
||
let parallels = "";
|
||
if(full){
|
||
for(let yy=by-50; yy>ay+24; yy-=50){
|
||
const hw = half * (yy-ay)/(by-ay) - 10;
|
||
if(hw < 30) break;
|
||
parallels += `<line x1="${ax-hw}" y1="${yy}" x2="${ax+hw}" y2="${yy}" stroke="${ink}" stroke-width="0.8" opacity=".35"/>`;
|
||
}
|
||
}
|
||
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+14}" font-size="${hc?12:10}" font-weight="bold" fill="${ink}" text-anchor="middle" opacity=".7">90°</text>
|
||
${parallels}${cmTicks}${degs}</svg>`;
|
||
/* tekenranden voor het kleven van de pen (bord-lokale coördinaten) */
|
||
t._edges = [[[14,by],[W-14,by]], [[14,by],[ax,ay]], [[W-14,by],[ax,ay]]];
|
||
const lenBtn = t.querySelector(".do-len");
|
||
lenBtn.textContent = cmLen + " cm";
|
||
lenBtn.title = T("dtLen");
|
||
const modeBtn = t.querySelector(".do-mode");
|
||
modeBtn.textContent = T(full ? "dtModeFull" : "dtModeBasic");
|
||
modeBtn.title = T("dtMode");
|
||
};
|
||
t.innerHTML = `<div class="dtool-svg"></div>
|
||
<div class="dtool-opts">
|
||
<button class="do-len do-wide"></button>
|
||
<button class="do-mode do-wide"></button>
|
||
<button class="do-hc">◐</button>
|
||
<button class="do-lock">🔓</button>
|
||
</div>`;
|
||
t.querySelector(".do-len").addEventListener("click", ()=>{
|
||
t._cm = GEO_CMS[(GEO_CMS.indexOf(t._cm)+1) % GEO_CMS.length]; drawGeo();
|
||
});
|
||
t.querySelector(".do-mode").addEventListener("click", ()=>{ t._full = !t._full; drawGeo(); });
|
||
const geoHc = t.querySelector(".do-hc");
|
||
geoHc.title = T("dtContrast");
|
||
geoHc.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);
|
||
/* slot: zet het gereedschap vast op zijn plek - niets kan hem meer
|
||
verschuiven of draaien, zodat er rustig langs getekend kan worden */
|
||
const lockBtn = t.querySelector(".do-lock");
|
||
const syncLock = ()=>{
|
||
lockBtn.textContent = t._locked ? "🔒" : "🔓";
|
||
lockBtn.title = T("dtLock");
|
||
lockBtn.setAttribute("aria-pressed", String(!!t._locked));
|
||
lockBtn.classList.toggle("on", !!t._locked);
|
||
rot.style.display = t._locked ? "none" : "";
|
||
};
|
||
lockBtn.addEventListener("click", ()=>{ t._locked = !t._locked; syncLock(); });
|
||
syncLock();
|
||
}
|
||
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, bMidX = null, bMidY = null, bUsedMark = false;
|
||
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)];
|
||
};
|
||
/* ---- langs de liniaal/geodriehoek tekenen -----------------------------------
|
||
Elke rand van de liniaal en geodriehoek is een "tekenrand". Begint een haal
|
||
binnen 16px van zo'n rand, dan wordt die rand voor de hele haal vergrendeld
|
||
en wordt elk punt erop geprojecteerd: een kaarsrechte lijn, precies zoals
|
||
tekenen tegen een echte lat. Verder dan ~60px van de rand laat de pen weer
|
||
los (bewust weggetrokken). Randen draaien mee met het gereedschap. */
|
||
let bSnap = null;
|
||
function toolEdges(){
|
||
const edges = [];
|
||
["ruler","geo"].forEach(kind=>{
|
||
const t = DTOOLS[kind];
|
||
if(!t || !t._edges) return;
|
||
const rad = (t._ang||0)*Math.PI/180;
|
||
const cosA = Math.cos(rad), sinA = Math.sin(rad);
|
||
const cx = t.offsetLeft + t.offsetWidth/2, cy = t.offsetTop + t.offsetHeight/2;
|
||
const rot = ([x,y])=>{
|
||
const dx = x - t.offsetWidth/2, dy = y - t.offsetHeight/2;
|
||
return [cx + dx*cosA - dy*sinA, cy + dx*sinA + dy*cosA];
|
||
};
|
||
t._edges.forEach(seg=>edges.push({ a:rot(seg[0]), b:rot(seg[1]), locked:!!t._locked }));
|
||
});
|
||
return edges;
|
||
}
|
||
function nearestEdge(px, py){
|
||
let best = null;
|
||
toolEdges().forEach(({a, b, locked})=>{
|
||
const ux = b[0]-a[0], uy = b[1]-a[1], len = Math.hypot(ux,uy);
|
||
if(len < 1) return;
|
||
const along = Math.max(0, Math.min(len, ((px-a[0])*ux + (py-a[1])*uy)/len));
|
||
const qx = a[0] + ux/len*along, qy = a[1] + uy/len*along;
|
||
const d = Math.hypot(px-qx, py-qy);
|
||
if(!best || d < best.d) best = { d, ax:a[0], ay:a[1], ux:ux/len, uy:uy/len, len, locked };
|
||
});
|
||
return best;
|
||
}
|
||
function snapPoint(x, y){
|
||
if(!bSnap) return [x, y];
|
||
const k = bInk.width / board.clientWidth; /* canvas-px per bord-px */
|
||
const px = x/k, py = y/k;
|
||
const along = Math.max(0, Math.min(bSnap.len, ((px-bSnap.ax)*bSnap.ux + (py-bSnap.ay)*bSnap.uy)));
|
||
const qx = bSnap.ax + bSnap.ux*along, qy = bSnap.ay + bSnap.uy*along;
|
||
if(Math.hypot(px-qx, py-qy) > 60){ bSnap = null; return [x, y]; }
|
||
return [qx*k, qy*k];
|
||
}
|
||
/* start van een haal; ook aanroepbaar vanaf de liniaal/geodriehoek zelf
|
||
(pen op de randzone van de lat = tekenen, niet verslepen) - de pointer
|
||
capture op bInk stuurt de vervolg-events vanzelf hierheen.
|
||
Naast de lat beslist de RICHTING: binnen 22px van een rand wachten we de
|
||
eerste ~7px beweging af; loopt die grofweg evenwijdig aan de rand (≤30°),
|
||
dan kleeft de haal - anders blijft hij vrij. Zo grijpt de lat geen vrije
|
||
halen en werkt langs de rand tekenen toch betrouwbaar. Op de lat zelf
|
||
(fromTool) is de intentie ondubbelzinnig en kleeft de haal direct. */
|
||
let bPend = null;
|
||
function startInkStroke(e, fromTool = false){
|
||
if(PEN.tool==="text"){ startBoardText(e); return; }
|
||
bInk.setPointerCapture(e.pointerId);
|
||
bDraw = true;
|
||
PEN.nib = PEN.size*0.8;
|
||
[bx,by] = bPos(e);
|
||
bSnap = null; bPend = null;
|
||
bMidX = bMidY = null;
|
||
if(PEN.tool!=="erase" && PEN.tool!=="hand"){
|
||
const k = bInk.width / board.clientWidth;
|
||
const hit = nearestEdge(bx/k, by/k);
|
||
if(fromTool){
|
||
if(hit && hit.d <= 24) bSnap = hit;
|
||
}else if(hit && hit.locked && hit.d <= 28){
|
||
/* vastgezet gereedschap = "hier teken ik langs": altijd direct kleven,
|
||
zonder richtingstoets - dat is waar het slot voor is */
|
||
bSnap = hit;
|
||
}else if(hit && hit.d <= 22){
|
||
bPend = { x0:bx, y0:by, hit };
|
||
return; /* nog niets tekenen; de eerste beweging beslist (flush hieronder) */
|
||
}
|
||
}
|
||
[bx,by] = snapPoint(bx, by);
|
||
bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType);
|
||
}
|
||
bInk.addEventListener("pointerdown", e=>{
|
||
if(e.pointerType==="mouse" && e.button!==0) return;
|
||
e.preventDefault();
|
||
startInkStroke(e);
|
||
});
|
||
bInk.addEventListener("pointermove", e=>{
|
||
if(!bDraw) return;
|
||
(e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{
|
||
const [rx,ry] = bPos(ev);
|
||
if(bPend){
|
||
const k = bInk.width / board.clientWidth;
|
||
const dx = rx - bPend.x0, dy = ry - bPend.y0;
|
||
/* een vinger wiebelt in de eerste millimeters: meet de richting over
|
||
een langere basis (10px) en wees ruimhartig met de hoek (≤40°) */
|
||
if(Math.hypot(dx, dy)/k < 10) return;
|
||
const move = Math.atan2(dy, dx), edge = Math.atan2(bPend.hit.uy, bPend.hit.ux);
|
||
let diff = Math.abs(move - edge) % Math.PI;
|
||
diff = Math.min(diff, Math.PI - diff);
|
||
if(diff <= Math.PI/4.5) bSnap = bPend.hit;
|
||
const [sx, sy] = snapPoint(bPend.x0, bPend.y0);
|
||
bx = sx; by = sy; bMidX = bMidY = null;
|
||
bStroke(sx+0.01, sy+0.01, ev.pressure, ev.pointerType);
|
||
bPend = null;
|
||
}
|
||
const [x,y] = snapPoint(rx, ry);
|
||
bStroke(x, y, ev.pressure, ev.pointerType);
|
||
});
|
||
});
|
||
const bStop = ()=>{
|
||
if(bDraw && bPend){
|
||
/* tik zonder beweging vlak naast de lat: gewoon een vrije stip zetten */
|
||
bx = bPend.x0; by = bPend.y0; bMidX = bMidY = null;
|
||
bStroke(bx+0.01, by+0.01, 0, "mouse");
|
||
bPend = null;
|
||
}
|
||
if(bDraw){
|
||
/* markeerstift: de haal staat dekkend op de overlay - nu in één keer
|
||
met vaste transparantie op de inktlaag zetten (egale kleur, geen
|
||
donkere overgangen) en de overlay leegmaken */
|
||
if(bUsedMark && bMark.width){
|
||
bCtx.globalCompositeOperation = "source-over";
|
||
bCtx.globalAlpha = bOverlayAlpha;
|
||
bCtx.drawImage(bMark, 0, 0);
|
||
bCtx.globalAlpha = 1;
|
||
bMarkCtx.clearRect(0, 0, bMark.width, bMark.height);
|
||
}
|
||
bUsedMark = false;
|
||
markBoardChange();
|
||
}
|
||
bDraw = false;
|
||
bSnap = null;
|
||
bCtx.globalAlpha = 1;
|
||
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
|
||
};
|
||
bInk.addEventListener("pointerup", bStop);
|
||
bInk.addEventListener("pointercancel", bStop);
|
||
/* gereedschappen met transparantie tekenen op de overlay-laag (dekkend) en
|
||
worden bij het loslaten in één keer doorgezet - egaal, zonder donkere
|
||
overgangen: potlood zacht grafiet-achtig, markeerstift doorschijnend */
|
||
const INK_ALPHA = { pen:0.85, mark:0.32 };
|
||
let bOverlayAlpha = 1;
|
||
function bStroke(x,y,pressure,ptype){
|
||
const layerAlpha = INK_ALPHA[PEN.tool];
|
||
if(layerAlpha){ bUsedMark = true; bOverlayAlpha = layerAlpha; bMark.style.opacity = layerAlpha; }
|
||
const ctx = layerAlpha ? bMarkCtx : bCtx;
|
||
ctx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over";
|
||
ctx.globalAlpha = 1;
|
||
ctx.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"){
|
||
/* kalligrafie met een 45°-penpunt: dik in de neerhaal (↘/↖ dwars op de
|
||
punt), dun in de ophaal (↗/↙ langs de punt) - het klassieke dik-dun
|
||
van een echte vulpen. Druk (stylus) versterkt het geheel nog. */
|
||
const dx = x - bx, dy = y - by;
|
||
let target = PEN.nib;
|
||
if(dx || dy){
|
||
const dir = Math.atan2(dy, dx);
|
||
const nib = Math.abs(Math.sin(dir - Math.PI/4));
|
||
target = PEN.size*(0.35 + nib*2.1);
|
||
}
|
||
if(ptype==="pen" && pressure > 0) target *= (0.55 + pressure*0.9);
|
||
PEN.nib += (target - PEN.nib)*0.35;
|
||
w = PEN.nib;
|
||
}
|
||
ctx.lineWidth = w*bDpr;
|
||
/* vloeiende haal: teken van middelpunt naar middelpunt met het vorige
|
||
punt als stuurpunt (kwadratische curve) - geen hoekige knikken meer
|
||
tussen de losse pointer-stapjes, ook bij snelle halen */
|
||
const mx = (bx + x) / 2, my = (by + y) / 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(bMidX ?? bx, bMidY ?? by);
|
||
ctx.quadraticCurveTo(bx, by, mx, my);
|
||
ctx.stroke();
|
||
bMidX = mx; bMidY = my;
|
||
bx = x; by = y;
|
||
}
|
||
function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } }
|
||
function setBoardInk(url){
|
||
bCtx.clearRect(0,0,bInk.width,bInk.height);
|
||
if(!url) return;
|
||
const im = new Image();
|
||
im.onload = ()=>bCtx.drawImage(im,0,0);
|
||
im.src = url;
|
||
}
|
||
refreshPenbar();
|
||
|
||
/* gallery */
|
||
const galleryWrap = document.getElementById("galleryWrap");
|
||
const gallery = document.getElementById("gallery");
|
||
function renderGallery(){
|
||
gallery.innerHTML = "";
|
||
const query=(document.getElementById("gallerySearch").value||"").trim().toLowerCase();
|
||
const favOnly=document.getElementById("galleryFavOnly").classList.contains("on");
|
||
let favorites=[];try{favorites=JSON.parse(localStorage.getItem("teachWidgetFavorites")||"[]")}catch(e){}
|
||
WIDGET_CATS.forEach(([cat,key])=>{
|
||
if(cat==="favorites"&&!favorites.length)return;
|
||
if(cat!=="favorites"&&favOnly)return;
|
||
let defs=cat==="favorites"?REGISTRY.filter(d=>favorites.includes(d.id)):REGISTRY.filter(d=>d.cat===cat);
|
||
defs=defs.filter(d=>(T("wg_"+d.id)+" "+T("wg_"+d.id+"_d")).toLowerCase().includes(query));
|
||
if(!defs.length)return;
|
||
const h=document.createElement("div");h.className="gcat-title";h.textContent=T(key);gallery.appendChild(h);
|
||
const grid=document.createElement("div");grid.className="gallery";
|
||
defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0;
|
||
const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon;
|
||
const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id);
|
||
/* uitleg: eigen tekst van de systeemmanager (CMS) gaat vóór de standaard */
|
||
const desc=document.createElement("div");desc.className="gd";desc.textContent=(typeof CMS!=="undefined"&&CMS.widgetInfo&&CMS.widgetInfo[def.id]&&(LANG==="en"?CMS.widgetInfo[def.id].en:CMS.widgetInfo[def.id].nl))||T("wg_"+def.id+"_d");
|
||
const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"★":"☆";star.title=T("galleryFavorite");
|
||
star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()};
|
||
const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid);
|
||
});
|
||
if(!gallery.children.length){const e=document.createElement("p");e.className="gallery-empty";e.textContent=T("galleryEmpty");gallery.append(e)}
|
||
}
|
||
galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); });
|
||
document.querySelector(".gallery-close").onclick=()=>galleryWrap.classList.remove("open");
|
||
const gallerySearch=document.getElementById("gallerySearch"),favOnlyBtn=document.getElementById("galleryFavOnly");gallerySearch.addEventListener("input",renderGallery);favOnlyBtn.onclick=()=>{favOnlyBtn.classList.toggle("on");renderGallery()};
|
||
const templates=[["templateMath","clock","numberline","fractions"],["templateLanguage","sentence","wordtypes","flits"],["templateDay","schedule","timer","mood"]];
|
||
function renderTemplates(){const host=document.getElementById("galleryTemplates");host.innerHTML="";templates.forEach(([key,...ids])=>host.append(btn(T(key),()=>{galleryWrap.classList.remove("open");ids.forEach((id,i)=>makeWidget(REGISTRY.find(d=>d.id===id),{x:24+i*45,y:24+i*38}))})));gallerySearch.placeholder=T("gallerySearch");gallerySearch.setAttribute("aria-label",T("gallerySearch"));favOnlyBtn.title=T("galleryFavorites");favOnlyBtn.setAttribute("aria-label",favOnlyBtn.title);document.getElementById("boardUndo").title=T("undo");document.getElementById("boardRedo").title=T("redo");document.getElementById("boardExport").textContent="⇩ "+T("exportBoard");document.getElementById("boardImport").textContent="⇧ "+T("importBoard");}
|
||
const importInput=document.createElement("input");importInput.type="file";importInput.accept="application/json";importInput.hidden=true;document.body.append(importInput);
|
||
document.getElementById("boardExport").onclick=()=>{const blob=new Blob([JSON.stringify(serializeBoard(),null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="teach-bord.json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)};
|
||
document.getElementById("boardImport").onclick=()=>importInput.click();importInput.onchange=()=>{const f=importInput.files[0];if(!f)return;if(f.size>2*1024*1024){alert(T("importError"));return}const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data||!Array.isArray(data.widgets)||data.widgets.length>100||(data.imgs||[]).length>50)throw Error();restoreBoard(data);markBoardChange()}catch(e){alert(T("importError"))}};r.readAsText(f);importInput.value=""};
|
||
function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave("boards")}}
|
||
document.getElementById("boardUndo").onclick=()=>historyMove(false);document.getElementById("boardRedo").onclick=()=>historyMove(true);document.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.target.matches("input,textarea,[contenteditable]")){e.preventDefault();historyMove(e.shiftKey)}});
|
||
|
||
/* ---------- floating add-button (FAB) ---------- */
|
||
const fab = document.getElementById("fab");
|
||
const fabMenu = document.getElementById("fabmenu");
|
||
function refreshFab(){
|
||
fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets");
|
||
fabMenu.querySelector('[data-f="anchor"] .fl').textContent = T("fabAnchor");
|
||
fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw");
|
||
fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind");
|
||
fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg");
|
||
fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen"));
|
||
}
|
||
fab.addEventListener("click", ()=>{
|
||
refreshFab();
|
||
fabMenu.classList.toggle("open");
|
||
const open = fabMenu.classList.contains("open");
|
||
fab.classList.toggle("open", open);
|
||
if(open) document.getElementById("boardStrip").classList.remove("open");
|
||
});
|
||
document.addEventListener("pointerdown", e=>{
|
||
if(!fabMenu.classList.contains("open")) return;
|
||
if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return;
|
||
fabMenu.classList.remove("open");
|
||
fab.classList.remove("open");
|
||
});
|
||
fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{
|
||
fabMenu.classList.remove("open"); fab.classList.remove("open");
|
||
renderTemplates(); renderGallery(); galleryWrap.classList.add("open");
|
||
});
|
||
fabMenu.querySelector('[data-f="anchor"]').addEventListener("click", ()=>{
|
||
fabMenu.classList.remove("open"); fab.classList.remove("open");
|
||
makeWidget(REGISTRY.find(d=>d.id==="anchor"));
|
||
});
|
||
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 catalogue + upload onto the board */
|
||
fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{
|
||
fabMenu.classList.remove("open"); fab.classList.remove("open");
|
||
openImageCatalog(src=>addBoardImage(src,80,90,320));
|
||
});
|
||
function loadImgFile(f, x, y){
|
||
if(currentUser && typeof uploadImageToCatalog==="function"){
|
||
uploadImageToCatalog(f).then(image=>addBoardImage(image.src,x,y,320))
|
||
.catch(err=>alert(err.message||T("imgUploadFailed")));
|
||
return;
|
||
}
|
||
/* gasten kunnen nog lokaal werken; hun niet-opgeslagen bord krijgt een
|
||
tijdelijke, verkleinde data-URL en gebruikt geen serverquota. */
|
||
const keepPng = /png|gif/.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);
|
||
}
|
||
/* drag & drop images from other websites or the computer onto the board */
|
||
board.addEventListener("dragover", e=>{ e.preventDefault(); });
|
||
board.addEventListener("drop", e=>{
|
||
e.preventDefault();
|
||
const zf = VZ();
|
||
const r = board.getBoundingClientRect();
|
||
const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70);
|
||
const dt = e.dataTransfer;
|
||
if(dt.files && dt.files.length){
|
||
[...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); });
|
||
return;
|
||
}
|
||
let url = null;
|
||
const html = dt.getData("text/html");
|
||
if(html){
|
||
const m = html.match(/<img[^>]+src\s*=\s*["']([^"']+)["']/i);
|
||
if(m) url = m[1];
|
||
}
|
||
if(!url){
|
||
const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0];
|
||
if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri;
|
||
else if(/^data:image\//.test(uri)) url = uri;
|
||
else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */
|
||
}
|
||
if(url) addBoardImage(url, x, y, 320);
|
||
});
|
||
function addBoardImage(src, x, y, w){
|
||
const d = document.createElement("div");
|
||
d.className = "bimg";
|
||
d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px";
|
||
const im = document.createElement("img");
|
||
im.src = src; im.draggable = false;
|
||
d.appendChild(im);
|
||
const xb = document.createElement("button");
|
||
xb.className = "dtool-x"; xb.textContent = "✕";
|
||
xb.addEventListener("click", ()=>{ d.remove(); markBoardChange(); });
|
||
d.appendChild(xb);
|
||
const rs = document.createElement("div");
|
||
rs.className = "bimg-rs";
|
||
rs.addEventListener("pointerdown", e=>{
|
||
e.preventDefault(); e.stopPropagation();
|
||
const zf = VZ();
|
||
const sw = d.offsetWidth, sx = e.clientX;
|
||
const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; };
|
||
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
|
||
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||
});
|
||
d.appendChild(rs);
|
||
d.addEventListener("pointerdown", e=>{
|
||
if(e.target===xb || e.target===rs) return;
|
||
if(e.pointerType==="mouse" && e.button!==0) return;
|
||
e.preventDefault();
|
||
const zf = VZ();
|
||
const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop;
|
||
const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; };
|
||
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
|
||
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||
});
|
||
board.appendChild(d);
|
||
markBoardChange();
|
||
return d;
|
||
}
|
||
document.addEventListener("langchange", refreshFab);
|
||
|
||
/* =========================================================
|
||
Multiple boards, organised in folders (quick nav next to +)
|
||
==========================================================*/
|
||
/* stabiel board-id, onafhankelijk van naam/positie - nodig zodat een toewijzing
|
||
aan een klas/leerling (zie pupil.js) een bord blijft vinden ook als het wordt
|
||
hernoemd of andere borden ervoor/erna worden toegevoegd/verwijderd */
|
||
function genBoardId(){ return "b" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); }
|
||
function newBoardEntry(name){ return { id: genBoardId(), name, data:null }; }
|
||
function freshBS(){
|
||
return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1",
|
||
boards:[newBoardEntry(LANG==="nl" ? "Bord 1" : "Board 1")] }],
|
||
f:0, b:0 };
|
||
}
|
||
let BS = freshBS();
|
||
function boardsFromData(data){
|
||
let bs;
|
||
if(data && data.boards && data.boards.folders && data.boards.folders.length) bs = data.boards;
|
||
else if(data && data.board){
|
||
bs = freshBS();
|
||
bs.folders[0].boards[0].data = data.board; /* migrate the old single board */
|
||
} else bs = freshBS();
|
||
/* borden opgeslagen vóór dit id-systeem bestond krijgen er hier alsnog één */
|
||
bs.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(!bo.id) bo.id = genBoardId(); }));
|
||
return bs;
|
||
}
|
||
function curSlot(){
|
||
if(BS.f >= BS.folders.length) BS.f = 0;
|
||
let fo = BS.folders[BS.f];
|
||
/* lege mappen bestaan sinds de verkenner: wijs dan uit naar een map mét
|
||
borden, of geef de actieve lege map alsnog een vers bord */
|
||
if(!fo.boards.length){
|
||
const fi = BS.folders.findIndex(f=>f.boards.length);
|
||
if(fi >= 0){ BS.f = fi; fo = BS.folders[BS.f]; }
|
||
else fo.boards.push(newBoardEntry(T("boardName")+" 1"));
|
||
}
|
||
if(BS.b >= fo.boards.length) BS.b = 0;
|
||
return fo.boards[BS.b];
|
||
}
|
||
function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; }
|
||
function stashCurrent(){ curSlot().data = serializeBoard(); }
|
||
function loadCurrentSlot(){
|
||
restoreBoard(curSlot().data || emptyBoardData());
|
||
updateBoardsUI();
|
||
}
|
||
function switchTo(f, b){
|
||
stashCurrent();
|
||
markBoardChange();
|
||
BS.f = f; BS.b = b;
|
||
loadCurrentSlot();
|
||
}
|
||
const btnFolders = document.getElementById("btnFolders");
|
||
/* ankerdiagram uit de bibliotheek openen vanuit de hoofdverkenner: plaatst
|
||
een nieuw ankerwidget op het huidige bord met dat diagram erin geladen */
|
||
function openAnchorOnBoard(entry){
|
||
const def = REGISTRY.find(d=>d.id==="anchor");
|
||
makeWidget(def, { state: {
|
||
cells: structuredClone(entry.cells || {}),
|
||
pics: structuredClone(entry.pics || {}),
|
||
}});
|
||
markBoardChange();
|
||
return null;
|
||
}
|
||
btnFolders.addEventListener("click", ()=>{
|
||
stashCurrent(); /* verse thumbnails, ook van het actieve bord */
|
||
openExplorer(withBranch(
|
||
"⚓ " + T("anchorLibTitle"),
|
||
withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks()),
|
||
withSharedBranches("anchor", anchorLibraryAdapter(openAnchorOnBoard), anchorLibrarySharedHooks(openAnchorOnBoard)),
|
||
{
|
||
wrongTypeMsg: T("exWrongBranch"),
|
||
branchCreateMsg: T("anchorLibCreateHint"),
|
||
branchCount: ()=>ANCHOR_LIB.length,
|
||
}
|
||
));
|
||
});
|
||
/* koppelstuk voor de gedeelde bibliotheek (zie withSharedBranches in
|
||
explorer.js): borden exporteren/importeren als {name, data}. Een kopie
|
||
krijgt altijd een nieuw bord-id en verse wids, anders zouden toewijzingen
|
||
en voortgang van verschillende gebruikers naar hetzelfde id verwijzen. */
|
||
function freshBoardCopy(name, data){
|
||
const d = data ? structuredClone(data) : null;
|
||
if(d && Array.isArray(d.widgets)) d.widgets.forEach(w=>{ w.wid = genWidgetId(); });
|
||
return { id: genBoardId(), name, data: d };
|
||
}
|
||
function boardsSharedHooks(){
|
||
const entryAtPath = path => BS.folders.find(f=>joinBoardPath(splitBoardPath(f.name))===joinBoardPath(path));
|
||
const ensureEntry = path => {
|
||
let e = entryAtPath(path);
|
||
if(!e){ e = { name: joinBoardPath(path), boards: [] }; BS.folders.push(e); }
|
||
return e;
|
||
};
|
||
const boardData = hit => (hit.fi===BS.f && hit.bi===BS.b) ? serializeBoard() : (hit.bo.data || emptyBoardData());
|
||
return {
|
||
exportItem(id){
|
||
const hit = findBoardEntry(id);
|
||
return hit ? { name: hit.bo.name, data: boardData(hit) } : null;
|
||
},
|
||
exportFolder(path){
|
||
const p = joinBoardPath(path);
|
||
const out = [];
|
||
BS.folders.forEach((f, fi)=>{
|
||
const j = joinBoardPath(splitBoardPath(f.name));
|
||
if(j === p || j.startsWith(p + "/")){
|
||
const rel = j === p ? [] : j.slice(p.length + 1).split("/");
|
||
f.boards.forEach((bo, bi)=>out.push({
|
||
name: bo.name, rel,
|
||
data: (fi===BS.f && bi===BS.b) ? serializeBoard() : (bo.data || emptyBoardData()),
|
||
}));
|
||
}
|
||
});
|
||
return out;
|
||
},
|
||
copyIn(item, destPath){
|
||
if(!destPath.length) return T("exNoRootItems");
|
||
ensureEntry(destPath).boards.push(freshBoardCopy(item.name, item.data));
|
||
markBoardChange();
|
||
return null;
|
||
},
|
||
openShared(item, relPath){
|
||
const entry = ensureEntry(relPath.length ? relPath : [T("shGlobal")]);
|
||
const copy = freshBoardCopy(item.name, item.data);
|
||
entry.boards.push(copy);
|
||
markBoardChange();
|
||
const hit = findBoardEntry(copy.id);
|
||
if(hit) switchTo(hit.fi, hit.bi);
|
||
return null;
|
||
},
|
||
};
|
||
}
|
||
function updateBoardsUI(){
|
||
btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`;
|
||
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
|
||
if(boardStrip.classList.contains("open")) renderStrip();
|
||
}
|
||
|
||
/* ---- verkenner-adapter: virtuele boom over de platte BS.folders-lijst;
|
||
nesting zit in de mapnaam als pad met "/"-scheiding ("Rekenen/Groep 4"),
|
||
dus bestaande data en de server (findBoardById op bord-id) blijven werken */
|
||
const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean);
|
||
const joinBoardPath = p => p.join("/");
|
||
function findBoardEntry(id){
|
||
for(let fi=0; fi<BS.folders.length; fi++){
|
||
const bi = BS.folders[fi].boards.findIndex(b=>b.id===id);
|
||
if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] };
|
||
}
|
||
return null;
|
||
}
|
||
function boardsExplorerAdapter(){
|
||
const norm = f => joinBoardPath(splitBoardPath(f.name));
|
||
const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path));
|
||
const findBoard = findBoardEntry;
|
||
const activeId = ()=>curSlot().id;
|
||
/* indices herstellen nadat entries verschoven/verwijderd zijn */
|
||
const relocate = (id)=>{
|
||
const hit = findBoard(id);
|
||
if(hit){ BS.f = hit.fi; BS.b = hit.bi; }
|
||
};
|
||
const mutated = ()=>{ markBoardChange(); updateBoardsUI(); };
|
||
return {
|
||
title: ()=>T("boardsNav"),
|
||
allowRootItems: false,
|
||
newItemLabel: ()=>T("newBoard"),
|
||
list(path){
|
||
const p = joinBoardPath(path);
|
||
const folderMap = new Map();
|
||
let items = [];
|
||
BS.folders.forEach((f, fi)=>{
|
||
const j = norm(f);
|
||
if(j === p){
|
||
items = f.boards.map((bo, bi)=>({
|
||
key: bo.id, name: bo.name,
|
||
active: fi===BS.f && bi===BS.b,
|
||
tile: ()=>boardThumb((fi===BS.f && bi===BS.b) ? serializeBoard() : bo.data),
|
||
}));
|
||
}else if(!p || j.startsWith(p + "/")){
|
||
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
|
||
if(seg) folderMap.set(seg, (folderMap.get(seg)||0) + f.boards.length);
|
||
}
|
||
});
|
||
const folders = [...folderMap].map(([name, count])=>({name, count}))
|
||
.sort((a,b)=>a.name.localeCompare(b.name));
|
||
return { folders, items };
|
||
},
|
||
createFolder(path, name){
|
||
const full = [...path, name];
|
||
if(entryAt(full) || this.list(path).folders.some(f=>f.name===name)) return T("exExists");
|
||
BS.folders.push({ name: joinBoardPath(full), boards: [] });
|
||
mutated();
|
||
return null;
|
||
},
|
||
renameFolder(path, oldName, newName){
|
||
return this.moveFolder([...path, oldName], path, newName);
|
||
},
|
||
moveFolder(srcPath, targetPath, newName){
|
||
const from = joinBoardPath(srcPath);
|
||
const to = joinBoardPath([...targetPath, newName || srcPath[srcPath.length-1]]);
|
||
if(from === to) return null;
|
||
if(BS.folders.some(f=>{ const j = norm(f); return j === to || j.startsWith(to + "/"); })) return T("exExists");
|
||
const id = activeId();
|
||
BS.folders.forEach(f=>{
|
||
const j = norm(f);
|
||
if(j === from || j.startsWith(from + "/")) f.name = to + j.slice(from.length);
|
||
});
|
||
relocate(id);
|
||
mutated();
|
||
return null;
|
||
},
|
||
deleteFolder(path, name){
|
||
const full = joinBoardPath([...path, name]);
|
||
const under = BS.folders.filter(f=>{ const j = norm(f); return j === full || j.startsWith(full + "/"); });
|
||
if(under.some(f=>f.boards.length)) return T("exFolderNotEmpty");
|
||
const id = activeId();
|
||
BS.folders = BS.folders.filter(f=>!under.includes(f));
|
||
if(!BS.folders.length) BS.folders.push({ name: T("folderName")+" 1", boards: [] });
|
||
relocate(id);
|
||
mutated();
|
||
return null;
|
||
},
|
||
createItem(path){
|
||
let entry = entryAt(path);
|
||
if(!entry){ entry = { name: joinBoardPath(path), boards: [] }; BS.folders.push(entry); }
|
||
entry.boards.push(newBoardEntry(`${T("boardName")} ${entry.boards.length+1}`));
|
||
mutated();
|
||
return null;
|
||
},
|
||
renameItem(id, name){
|
||
const hit = findBoard(id);
|
||
if(!hit) return null;
|
||
hit.bo.name = name;
|
||
mutated();
|
||
return null;
|
||
},
|
||
moveItem(id, targetPath){
|
||
const hit = findBoard(id);
|
||
if(!hit) return null;
|
||
let entry = entryAt(targetPath);
|
||
if(!entry){ entry = { name: joinBoardPath(targetPath), boards: [] }; BS.folders.push(entry); }
|
||
if(entry === hit.fo) return null;
|
||
const act = activeId();
|
||
hit.fo.boards.splice(hit.bi, 1);
|
||
entry.boards.push(hit.bo);
|
||
relocate(act);
|
||
mutated();
|
||
return null;
|
||
},
|
||
deleteItem(id){
|
||
const hit = findBoard(id);
|
||
if(!hit) return null;
|
||
if(flatBoards().length <= 1) return T("exLastBoard");
|
||
const wasActive = id === activeId();
|
||
hit.fo.boards.splice(hit.bi, 1);
|
||
if(wasActive){ BS.f = 0; BS.b = 0; loadCurrentSlot(); }
|
||
else relocate(activeId());
|
||
mutated();
|
||
return null;
|
||
},
|
||
openItem(id){
|
||
const hit = findBoard(id);
|
||
if(hit && !(hit.fi===BS.f && hit.bi===BS.b)) switchTo(hit.fi, hit.bi);
|
||
},
|
||
};
|
||
}
|
||
function flatBoards(){
|
||
const out = [];
|
||
BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo})));
|
||
return out;
|
||
}
|
||
|
||
/* ---- quick switch: hover the 🗂 button → previews side by side ---- */
|
||
const fabBoardsBtn = document.getElementById("fabBoardsBtn");
|
||
const boardStrip = document.getElementById("boardStrip");
|
||
let stripTimer = null;
|
||
function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); }
|
||
function closeStrip(){ boardStrip.classList.remove("open"); }
|
||
fabBoardsBtn.addEventListener("click", ()=>{
|
||
boardStrip.classList.contains("open") ? closeStrip() : openStrip();
|
||
});
|
||
fabBoardsBtn.addEventListener("mouseenter", openStrip);
|
||
fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
|
||
boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer));
|
||
boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
|
||
document.addEventListener("pointerdown", e=>{
|
||
if(!boardStrip.classList.contains("open")) return;
|
||
if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return;
|
||
closeStrip();
|
||
});
|
||
boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; });
|
||
|
||
/* mini preview of a board's contents */
|
||
function boardThumb(data){
|
||
const w = 168, h = 105;
|
||
const c = document.createElement("canvas");
|
||
c.width = w; c.height = h;
|
||
const g = c.getContext("2d");
|
||
g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
|
||
if(!data) return c;
|
||
const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1);
|
||
(data.imgs||[]).forEach(im=>{
|
||
g.fillStyle = "#d8e2ef";
|
||
g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh));
|
||
});
|
||
(data.widgets||[]).forEach(wd=>{
|
||
const x = wd.x*w/bw, y = wd.y*h/bh,
|
||
ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh);
|
||
g.fillStyle = "rgba(59,125,216,.14)";
|
||
g.fillRect(x, y, ww, wh);
|
||
g.strokeStyle = "rgba(59,125,216,.55)";
|
||
g.strokeRect(x, y, ww, wh);
|
||
const def = REGISTRY.find(d=>d.id===wd.id);
|
||
if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); }
|
||
});
|
||
if(data.ink){
|
||
const img = new Image();
|
||
img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} };
|
||
img.src = data.ink;
|
||
}
|
||
return c;
|
||
}
|
||
function renderStrip(){
|
||
boardStrip.innerHTML = "";
|
||
const items = flatBoards();
|
||
items.forEach(it=>{
|
||
const isCur = it.fi===BS.f && it.bi===BS.b;
|
||
const card = document.createElement("div");
|
||
card.className = "bs-card" + (isCur ? " active" : "");
|
||
card.title = `${it.fo.name} / ${it.bo.name}`;
|
||
card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data));
|
||
const row = document.createElement("div");
|
||
row.className = "bs-name";
|
||
const nm = document.createElement("span");
|
||
nm.textContent = it.bo.name;
|
||
row.appendChild(nm);
|
||
if(items.length > 1){
|
||
const x = document.createElement("button");
|
||
x.className = "bs-x"; x.textContent = "✕";
|
||
x.title = T("delBoard");
|
||
x.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||
x.addEventListener("click", ev=>{
|
||
ev.stopPropagation();
|
||
if(!x.dataset.arm){ /* first click arms, second click deletes */
|
||
clearTimeout(stripTimer); /* keep the strip open while confirming */
|
||
x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?";
|
||
setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000);
|
||
return;
|
||
}
|
||
const fo = BS.folders[it.fi];
|
||
fo.boards.splice(it.bi, 1);
|
||
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
|
||
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
|
||
else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
|
||
markBoardChange();
|
||
renderStrip();
|
||
});
|
||
row.appendChild(x);
|
||
}
|
||
card.appendChild(row);
|
||
card.addEventListener("click", ()=>{
|
||
if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); }
|
||
});
|
||
boardStrip.appendChild(card);
|
||
});
|
||
const add = document.createElement("div");
|
||
add.className = "bs-card bs-new";
|
||
add.textContent = "+";
|
||
add.title = T("newBoard");
|
||
add.addEventListener("click", ()=>{
|
||
const fo = BS.folders[BS.f];
|
||
fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`));
|
||
switchTo(BS.f, fo.boards.length-1);
|
||
renderStrip();
|
||
});
|
||
boardStrip.appendChild(add);
|
||
}
|
||
document.addEventListener("langchange", updateBoardsUI);
|