/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */ /* ========================================================= Widget framework ==========================================================*/ const board = document.getElementById("board"); /* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */ let BOARD_HISTORY=[], BOARD_FUTURE=[], HISTORY_TIMER=null, HISTORY_BUSY=false; function recordBoardHistory(){ if(RESTORING||HISTORY_BUSY) return; clearTimeout(HISTORY_TIMER); HISTORY_TIMER=setTimeout(()=>{ const snap=serializeBoard(), sig=JSON.stringify(snap); if(!BOARD_HISTORY.length||JSON.stringify(BOARD_HISTORY.at(-1))!==sig){BOARD_HISTORY.push(snap);if(BOARD_HISTORY.length>30)BOARD_HISTORY.shift();BOARD_FUTURE=[];} },250); } const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave("boards"); recordBoardHistory(); } }; /* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen, schrijfblad, spelvoortgang): elke interactie in een widget-body plant een (debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk te instrumenteren */ document.addEventListener("input", e=>{ if(e.target.closest && e.target.closest(".widget-body")) markBoardChange(); }, true); document.addEventListener("pointerup", e=>{ if(e.target.closest && e.target.closest(".widget-body")) markBoardChange(); }, true); const dock = document.getElementById("dock"); let zTop = 10, widgetCount = 0; const instances = new Set(); const REGISTRY = [ { id:"letters", cat:"taal", icon:"πŸ”€", w:760, h:640, minW:640, minH:540, mount:mountLetterGame }, { id:"flits", cat:"taal", icon:"⚑", w:520, h:430, minW:400, minH:330, mount:mountFlits }, { id:"hak", cat:"taal", icon:"βœ‚οΈ", w:540, h:440, minW:420, minH:340, mount:mountHak }, { id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall }, { id:"anchor", cat:"taal", icon:"βš“", w:680, h:500, minW:440, minH:340, mount:mountAnchor }, { id:"sentence", cat:"taal", icon:"🧩", w:620, h:520, minW:400, minH:360, mount:mountSentence }, { id:"madd", cat:"rekenen", icon:"βž•", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"add",o) }, { id:"msub", cat:"rekenen", icon:"βž–", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"sub",o) }, { id:"mmul", cat:"rekenen", icon:"βœ–οΈ", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"mul",o) }, { id:"mdiv", cat:"rekenen", icon:"βž—", w:460, h:620, minW:380, minH:520, mount:(r,s,o)=>mountMath(r,s,"div",o) }, { id:"mpct", cat:"rekenen", icon:"πŸ’―", w:480, h:620, minW:400, minH:520, mount:(r,s,o)=>mountMath(r,s,"pct",o) }, { id:"numberline", cat:"rekenen", icon:"↔️", w:660, h:430, minW:420, minH:320, mount:mountNumberline }, { id:"fractions", cat:"rekenen", icon:"🍫", w:600, h:480, minW:380, minH:320, mount:mountFractions }, { id:"clock", cat:"rekenen", icon:"πŸ•™", w:500, h:500, minW:340, minH:340, mount:mountClock }, { id:"placevalue", cat:"rekenen", icon:"πŸ”’", w:610, h:390, minW:390, minH:280, mount:mountPlacevalue }, { id:"money", cat:"rekenen", icon:"πŸͺ™", w:520, h:430, minW:360, minH:300, mount:mountMoney }, { id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"mind", cat:"tools", icon:"πŸ•ΈοΈ", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"notes", cat:"tools", icon:"πŸ“", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, { id:"birthday",cat:"class", icon:"πŸŽ‚", w:620, h:560, minW:400, minH:400, mount:mountBirthday }, { id:"schedule", cat:"class", icon:"πŸ“…", w:500, h:520, minW:340, minH:340, mount:mountSchedule }, { id:"mood", cat:"class", icon:"🌑️", w:530, h:360, minW:350, minH:260, mount:mountMood }, { id:"poll", cat:"class", icon:"πŸ“Š", w:540, h:500, minW:360, minH:330, mount:mountPoll }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; /* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */ function clampWidgetSize(w, h, def){ const fw = Math.max(def.minW || 240, Math.min(w, board.clientWidth)); const fh = Math.max(def.minH || 180, Math.min(h, board.clientHeight)); return { w: fw, h: fh }; } /* stabiel widget-instantie-id, zelfde patroon als genBoardId() verderop - nodig zodat een toewijzing van één enkele widget (zie pupil.js/admin.js) die widget blijft vinden ook als er widgets vΓ³Γ³r/na worden toegevoegd of verplaatst */ function genWidgetId(){ return "w" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); } function makeWidget(def, saved){ const win = document.createElement("div"); win.className = "widget"; const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def); win.style.width = size.w+"px"; win.style.height = size.h+"px"; if(def.minW) win.style.minWidth = def.minW+"px"; if(def.minH) win.style.minHeight = def.minH+"px"; const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240; const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160; win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px"; win.style.top = Math.max(0, Math.min(cy, board.clientHeight - size.h - 8)) + "px"; win.style.zIndex = ++zTop; widgetCount++; if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } if(saved && saved.locked){ win.dataset.locked="1"; } const head = document.createElement("div"); head.className = "widget-head"; head.innerHTML = `${def.icon}`; const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn); const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="πŸ”“"; head.appendChild(lockBtn); const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="β—‰"; head.appendChild(previewBtn); const zBtn = document.createElement("button"); zBtn.className = "wmin wz"; zBtn.textContent = "AοΌ‹"; head.appendChild(zBtn); const minBtn = document.createElement("button"); minBtn.className = "wmin"; minBtn.textContent = "–"; head.appendChild(minBtn); const maxBtn = document.createElement("button"); maxBtn.className = "wmin"; maxBtn.textContent = "β–’"; head.appendChild(maxBtn); const close = document.createElement("button"); close.className = "wclose"; close.textContent = "βœ•"; head.appendChild(close); const body = document.createElement("div"); body.className = "widget-body"; win.appendChild(head); win.appendChild(body); /* eigen resize-greep (touch-vriendelijk, vervangt de native browser-grip) */ const rs = document.createElement("div"); rs.className = "widget-rs"; rs.addEventListener("pointerdown", e=>{ if(win.dataset.locked==="1") return; e.preventDefault(); e.stopPropagation(); win.style.zIndex = ++zTop; const zf = VZ(); const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY; const mv = ev=>{ const maxW = board.clientWidth - win.offsetLeft - 8; const maxH = board.clientHeight - win.offsetTop - 8; let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf; nw = Math.max(def.minW || 240, Math.min(nw, maxW)); nh = Math.max(def.minH || 180, Math.min(nh, maxH)); win.style.width = nw+"px"; win.style.height = nh+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); win.appendChild(rs); const applyLock=()=>{const locked=win.dataset.locked==="1";win.classList.toggle("locked",locked);lockBtn.textContent=locked?"πŸ”’":"πŸ”“";lockBtn.title=T(locked?"widgetUnlock":"widgetLock");lockBtn.setAttribute("aria-label",lockBtn.title);lockBtn.setAttribute("aria-pressed",String(locked))}; lockBtn.addEventListener("click",()=>{win.dataset.locked=win.dataset.locked==="1"?"":"1";applyLock();markBoardChange()}); previewBtn.addEventListener("click",()=>{win.classList.toggle("preview");previewBtn.classList.toggle("on",win.classList.contains("preview"));previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-pressed",String(win.classList.contains("preview"))) }); applyLock(); let pill = null; function setMin(m){ if(m && !pill){ win.dataset.prevW = win.offsetWidth; win.dataset.prevH = win.offsetHeight; win.dataset.min = "1"; win.style.display = "none"; pill = document.createElement("button"); pill.className = "dock-item"; pill.innerHTML = `${def.icon}`; pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); pill.addEventListener("click", ()=>setMin(false)); dock.appendChild(pill); }else if(!m && pill){ pill.remove(); pill = null; win.dataset.min = ""; win.style.display = "flex"; win.style.zIndex = ++zTop; } } minBtn.addEventListener("click", ()=>{ setMin(true); markBoardChange(); }); /* per-widget content zoom for low-vision pupils */ let wz = (saved && saved.wz) || 1; function applyWZ(){ body.style.zoom = wz; win.dataset.wz = wz; zBtn.textContent = wz===1 ? "AοΌ‹" : Math.round(wz*100)+"%"; zBtn.classList.toggle("on", wz!==1); zBtn.title = T("wZoom"); } zBtn.addEventListener("click", ()=>{ wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; applyWZ(); markBoardChange(); }); applyWZ(); function setMax(m){ if(m && win.dataset.max!=="1"){ win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop; win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight; win.dataset.max = "1"; win.style.left = "0px"; win.style.top = "0px"; win.style.width = board.clientWidth+"px"; win.style.height = board.clientHeight+"px"; win.style.zIndex = ++zTop; maxBtn.textContent = "❐"; setHudMin(true); /* keep the tools bar out of the way */ }else if(!m && win.dataset.max==="1"){ win.dataset.max = ""; win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px"; win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px"; maxBtn.textContent = "β–’"; } maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); } maxBtn.addEventListener("click", ()=>{ setMax(win.dataset.max!=="1"); markBoardChange(); }); head.addEventListener("dblclick", e=>{ if(e.target===close || e.target===minBtn || e.target===maxBtn) return; setMin(true); }); const setTitle = ()=>{ head.querySelector(".wtitle").textContent = T("wg_"+def.id); minBtn.title = T("wMin"); maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); zBtn.title = T("wZoom"); if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); } }; setTitle(); document.addEventListener("langchange", setTitle); win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); head.addEventListener("pointerdown", e=>{ if(win.dataset.locked==="1" || e.target.closest("button")) return; e.preventDefault(); const zf = VZ(); const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop; const move = ev=>{ win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px"; win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px"; }; const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); markBoardChange(); }; document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); }); board.appendChild(win); const api = def.mount(body, saved ? saved.state : null) || {}; if(saved && saved.min) setMin(true); if(saved && saved.max) setMax(true); const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() }; dupBtn.title=T("widgetDuplicate");dupBtn.setAttribute("aria-label",dupBtn.title);previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-label",previewBtn.title); dupBtn.addEventListener("click",()=>makeWidget(def,{x:win.offsetLeft+28,y:win.offsetTop+28,w:win.offsetWidth,h:win.offsetHeight,wz:+win.dataset.wz||1,state:api.getState?structuredClone(api.getState()):null})); instances.add(inst); close.addEventListener("click", ()=>{ markBoardChange(); if(pill){ pill.remove(); pill = null; } instances.delete(inst); win.remove(); updateEmptyHint(); }); updateEmptyHint(); markBoardChange(); return inst; } function updateEmptyHint(){ document.body.classList.toggle("board-empty", !board.querySelector(".widget")); } function serializeBoard(){ return { ink: boardInkURL(), bg: BOARD.bg, widgets: [...instances].map(inst=>{ const w = inst.win; const min = w.dataset.min === "1"; const max = w.dataset.max === "1"; return { id: inst.def.id, wid: inst.wid, x: max ? +w.dataset.pmx : w.offsetLeft, y: max ? +w.dataset.pmy : w.offsetTop, w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth, h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight, min, max, wz: +w.dataset.wz || 1, bare: w.dataset.bare === "1", locked: w.dataset.locked === "1", state: inst.api.getState ? inst.api.getState() : null }; }), imgs: [...board.querySelectorAll(".bimg")].map(b=>({ src: b.querySelector("img").src, x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth })) }; } /* tijdens het terugzetten van een bord geen autosaves plannen: het herstellen zelf is geen wijziging (en zou anders direct na inloggen al een save triggeren) */ let RESTORING = false; function restoreBoard(b){ RESTORING = true; [...instances].forEach(inst=>inst.win.remove()); instances.clear(); dock.innerHTML = ""; widgetCount = 0; (b.widgets||[]).forEach(wd=>{ const def = REGISTRY.find(d=>d.id===wd.id); if(def) makeWidget(def, wd); }); setBoardInk(b.ink); setBoardBg(b.bg || "dots"); board.querySelectorAll(".bimg").forEach(x=>x.remove()); (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); updateEmptyHint(); RESTORING = false; if(!HISTORY_BUSY){BOARD_HISTORY=[serializeBoard()];BOARD_FUTURE=[];} } /* ---------- board drawing layer (pen mode) ---------- */ const bInk = document.createElement("canvas"); bInk.id = "boardInk"; board.insertBefore(bInk, board.firstChild); const bCtx = bInk.getContext("2d"); const bDpr = window.devicePixelRatio || 1; function bResize(){ const w = board.clientWidth, h = board.clientHeight; if(w<2 || h<2) return; const keep = document.createElement("canvas"); keep.width = bInk.width; keep.height = bInk.height; if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0); bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr); if(keep.width) bCtx.drawImage(keep,0,0); bCtx.lineCap = "round"; bCtx.lineJoin = "round"; } new ResizeObserver(bResize).observe(board); bResize(); const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 }; const DEFAULT_PEN_COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"]; const RECENT_COLOR_KEY = "teachRecentPenColors"; let recentPenColors = []; try{ recentPenColors = JSON.parse(localStorage.getItem(RECENT_COLOR_KEY) || "[]").filter(c=>/^#[0-9a-f]{6}$/i.test(c)).slice(0,6); }catch(e){} const BOARD = { bg:"blank" }; const penbar = document.getElementById("penbar"); function closePenPickers(except){ penbar.querySelectorAll(".pen-picker.open").forEach(p=>{ if(p===except) return; p.classList.remove("open"); p.querySelector(".pen-current").setAttribute("aria-expanded","false"); }); } penbar.querySelectorAll(".pen-picker").forEach(p=>{ p.querySelector(".pen-current").addEventListener("click", e=>{ e.stopPropagation(); const open = !p.classList.contains("open"); closePenPickers(); p.classList.toggle("open", open); p.querySelector(".pen-current").setAttribute("aria-expanded", String(open)); }); }); document.addEventListener("pointerdown", e=>{ if(!e.target.closest(".pen-picker")) closePenPickers(); }); function makeColorButton(c, host){ const b = document.createElement("button"); b.className = "wp-color"; b.dataset.c = c; b.type = "button"; const dot = document.createElement("i"); dot.style.background = c; b.appendChild(dot); b.addEventListener("click", ()=>selectPenColor(c)); host.appendChild(b); } function renderRecentPenColors(){ const host = penbar.querySelector(".pen-recent"); host.innerHTML = ""; recentPenColors.forEach(c=>makeColorButton(c, host)); penbar.querySelector(".pen-recent-label").hidden = recentPenColors.length===0; host.hidden = recentPenColors.length===0; } function selectPenColor(c){ PEN.color = c.toLowerCase(); if(PEN.tool==="erase") PEN.tool = "pen"; recentPenColors = [PEN.color, ...recentPenColors.filter(x=>x!==PEN.color)].slice(0,6); try{ localStorage.setItem(RECENT_COLOR_KEY, JSON.stringify(recentPenColors)); }catch(e){} renderRecentPenColors(); closePenPickers(); refreshPenbar(); } function setPen(on){ document.body.classList.toggle("pen", on); document.body.classList.toggle("pen-text", on && PEN.tool==="text"); refreshPenbar(); } function setBoardBg(bg){ const changed = BOARD.bg !== bg; BOARD.bg = bg; board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; refreshPenbar(); if(changed) markBoardChange(); } const PEN_TOOLS = [["hand","βœ‹","toolHand"],["pen","✏️","toolPen"],["vulp","πŸ–‹οΈ","toolFountain"],["mark","πŸ–οΈ","toolMark"],["text","πŸ”€","toolText"],["erase","🧽","eraser"]]; PEN_TOOLS.forEach(([id,icon])=>{ const b = document.createElement("button"); b.dataset.tool = id; b.textContent = icon; b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); closePenPickers(); refreshPenbar(); }); penbar.querySelector(".pen-tools").appendChild(b); }); DEFAULT_PEN_COLORS.forEach(c=>makeColorButton(c, penbar.querySelector(".pen-colors"))); renderRecentPenColors(); [3,5,8].forEach(s=>{ const b = document.createElement("button"); b.className = "wp-size"; b.dataset.s = s; const i = document.createElement("i"); i.style.width = i.style.height = (s+4)+"px"; b.appendChild(i); b.addEventListener("click", ()=>{ PEN.size = s; closePenPickers(); refreshPenbar(); }); penbar.querySelector(".pen-sizes").appendChild(b); }); const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]]; PEN_BGS.forEach(([id])=>{ const b = document.createElement("button"); b.className = "wp-bg"; b.dataset.bg = id; b.appendChild(document.createElement("i")); b.addEventListener("click", ()=>{ setBoardBg(id); closePenPickers(); }); penbar.querySelector(".pen-bgs").appendChild(b); }); const PEN_EXTRAS = [["ruler","πŸ“","toolRuler"],["geo","πŸ“","toolGeo"],["lens","πŸ”","toolLens"],["light","🚦","wg_light"]]; PEN_EXTRAS.forEach(([id,icon])=>{ const b = document.createElement("button"); b.dataset.extra = id; b.textContent = icon; b.addEventListener("click", ()=>{ toggleDTool(id); closePenPickers(); }); penbar.querySelector(".pen-extras").appendChild(b); }); const customPenColor = penbar.querySelector(".pen-custom input"); customPenColor.addEventListener("input", ()=>selectPenColor(customPenColor.value)); customPenColor.addEventListener("click", e=>e.stopPropagation()); penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height)); /* the ✍-button toggles drawing mode on/off (palette itself stays visible) */ penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen"))); /* orientation + collapse of the whole bar */ document.querySelector(".hud-orient").addEventListener("click", ()=>{ document.getElementById("hud").classList.toggle("vert"); }); document.querySelector(".hud-min").addEventListener("click", function(){ setHudMin(!document.getElementById("hud").classList.contains("min")); }); /* unified draggable bar (pen palette + dock) */ const hud = document.getElementById("hud"); hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{ e.preventDefault(); const zf = VZ(); const r = hud.getBoundingClientRect(); hud.style.transform = "none"; hud.style.bottom = "auto"; hud.style.left = (r.left/zf)+"px"; hud.style.top = (r.top/zf)+"px"; const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf; const mv = ev=>{ hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px"; hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); function setHudMin(m){ hud.classList.toggle("min", m); const b = document.querySelector(".hud-min"); if(b) b.textContent = m ? "οΌ‹" : "–"; } function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); } new MutationObserver(updateHud).observe(dock, {childList:true}); updateHud(); function refreshPenbar(){ const toolDef = PEN_TOOLS.find(x=>x[0]===PEN.tool); penbar.querySelector('[data-picker="tools"] .pen-current-value').textContent = toolDef[1]; penbar.querySelector('[data-picker="tools"] .pen-current').title = T(toolDef[2]); const currentColor = penbar.querySelector(".pen-current-color"); currentColor.style.background = PEN.color; penbar.querySelector('[data-picker="colors"] .pen-current').title = T("penColor"); const currentSize = penbar.querySelector(".pen-current-size"); currentSize.style.width = currentSize.style.height = (PEN.size+4)+"px"; penbar.querySelector('[data-picker="sizes"] .pen-current').title = T("penSize"); const currentBg = penbar.querySelector(".pen-current-bg"); currentBg.className = "pen-current-bg bg-"+BOARD.bg; penbar.querySelector('[data-picker="backgrounds"] .pen-current').title = T(PEN_BGS.find(x=>x[0]===BOARD.bg)[1]); penbar.querySelector('[data-picker="extras"] .pen-current').title = T("penExtras"); penbar.querySelector(".pen-recent-label").textContent = T("recentColors"); penbar.querySelector(".pen-palette-label").textContent = T("paletteColors"); penbar.querySelector(".pen-custom-label").textContent = T("customColor"); customPenColor.value = PEN.color; penbar.querySelectorAll(".pen-tools button").forEach(b=>{ b.classList.toggle("on", b.dataset.tool===PEN.tool); b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]); }); penbar.querySelectorAll(".wp-color").forEach(b=> b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase")); penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size)); penbar.querySelectorAll(".wp-bg").forEach(b=>{ b.classList.toggle("on", b.dataset.bg===BOARD.bg); b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]); }); penbar.querySelectorAll(".pen-extras button").forEach(b=>{ b.classList.toggle("on", !!DTOOLS[b.dataset.extra]); b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]); }); penbar.querySelector(".pen-clear").title = T("clearAll"); const power = penbar.querySelector(".pen-close"); const penOn = document.body.classList.contains("pen"); power.textContent = "✍"; power.title = T("penMode"); power.classList.toggle("on", penOn); document.querySelector(".hud-orient").title = T("hudOrient"); document.querySelector(".hud-min").title = T("hudMin"); document.body.classList.toggle("pen-text", penOn && PEN.tool==="text"); document.body.classList.toggle("pen-hand", PEN.tool==="hand"); } document.addEventListener("langchange", refreshPenbar); /* ---------- physical tools: ruler, set square, magnifier ---------- */ const DTOOLS = {}; function makeMovable(t){ let ang = 0; t.addEventListener("pointerdown", e=>{ if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return; e.preventDefault(); e.stopPropagation(); if(e.target.classList.contains("dtool-rot")){ const r = t.getBoundingClientRect(); const cx = r.left+r.width/2, cy = r.top+r.height/2; const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang; const mv = ev=>{ ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start; t.style.transform = `rotate(${ang}deg)`; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); return; } const zf = VZ(); const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop; const mv = ev=>{ t.style.left = (ev.clientX/zf-sx)+"px"; t.style.top = (ev.clientY/zf-sy)+"px"; if(t.lensDraw) t.lensDraw(); }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); } function toggleDTool(kind){ if(DTOOLS[kind]){ if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv); DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return; } const t = document.createElement("div"); t.className = "dtool dtool-"+kind; if(kind==="ruler"){ t._len = 600; t._hc = false; const drawRuler = ()=>{ const L = t._len, hc = t._hc; const ink = hc ? "#000" : "#5a4a20"; const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)"; let ticks = ""; for(let i=5; i`; if(cm) ticks += `${i/50}`; } t.style.width = L+"px"; t.querySelector(".dtool-svg").innerHTML = ` teach Β· cm ${ticks}`; }; t.innerHTML = `
`; 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 += ``; }); if(cm){ [ax-i, ax+i].forEach(x=>{ cmTicks += `${i/50}`; }); } } /* 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 += ``; if(d%30===0 && d>=30 && d<=150){ const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22); degs += `${d}Β°`; } } t.style.width = W+"px"; t.style.height = H+"px"; t.querySelector(".dtool-svg").innerHTML = ` 0 ${cmTicks}${degs}`; }; t.innerHTML = `
`; 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 = `
`; const apply = ()=>t.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on)); t.querySelectorAll(".tl-lamp").forEach(l=>{ l.addEventListener("pointerdown", e=>e.stopPropagation()); l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); }); }); apply(); t.style.left = (board.clientWidth-140)+"px"; t.style.top = "20px"; }else{ /* lens: magnifies background, pictures and ink (2x); resizable, round or square */ const Z = 2, LMIN = 120, LMAX = 340; t._d = 184; t._sq = false; const c = document.createElement("canvas"); t.appendChild(c); t.lensDraw = ()=>{ const d = t._d, S = Z*2; /* canvas is 2x for sharpness */ const ctx2 = c.getContext("2d"); const cx = t.offsetLeft + d/2 + 2, cy = t.offsetTop + d/2 + 2; /* board css px */ const sw = d/Z; const sx = cx - sw/2, sy = cy - sw/2; ctx2.fillStyle = "#eef3f8"; ctx2.fillRect(0, 0, d*2, d*2); if(BOARD.bg === "dots"){ ctx2.fillStyle = "#c9d6e5"; const g = 26; for(let gx = Math.floor(sx/g)*g; gx < sx+sw; gx += g){ for(let gy = Math.floor(sy/g)*g; gy < sy+sw; gy += g){ ctx2.beginPath(); ctx2.arc((gx-sx)*S, (gy-sy)*S, 1.4*S, 0, 7); ctx2.fill(); } } } board.querySelectorAll(".bimg").forEach(b=>{ const im = b.querySelector("img"); if(!im.complete || !im.naturalWidth) return; try{ ctx2.drawImage(im, (b.offsetLeft-sx)*S, (b.offsetTop-sy)*S, b.offsetWidth*S, b.offsetHeight*S); }catch(e){} }); try{ ctx2.drawImage(bInk, sx*bDpr, sy*bDpr, sw*bDpr, sw*bDpr, 0, 0, d*2, d*2); }catch(e){} }; const lensSetup = ()=>{ const d = t._d; c.width = d*2; c.height = d*2; c.style.width = d+"px"; c.style.height = d+"px"; c.style.borderRadius = t._sq ? "16px" : "50%"; t.style.width = (d+4)+"px"; t.style.height = (d+4)+"px"; t.lensDraw(); }; const opts = document.createElement("div"); opts.className = "dtool-opts"; [["βˆ’","lensSmall",()=>{ t._d = Math.max(LMIN, t._d-40); lensSetup(); }], ["οΌ‹","lensBig", ()=>{ t._d = Math.min(LMAX, t._d+40); lensSetup(); }], ["β–’","lensShape",()=>{ t._sq = !t._sq; lensSetup(); }]].forEach(([txt,key,fn])=>{ const b = document.createElement("button"); b.textContent = txt; b.title = T(key); b.addEventListener("click", fn); opts.appendChild(b); }); t.appendChild(opts); lensSetup(); t._iv = setInterval(()=>t.lensDraw(), 350); } const x = document.createElement("button"); x.className = "dtool-x"; x.textContent = "βœ•"; x.addEventListener("click", ()=>{ if(t._iv) clearInterval(t._iv); t.remove(); delete DTOOLS[kind]; refreshPenbar(); }); t.appendChild(x); if(kind==="ruler" || kind==="geo"){ const rot = document.createElement("button"); rot.className = "dtool-rot"; rot.textContent = "↻"; t.appendChild(rot); } makeMovable(t); board.appendChild(t); DTOOLS[kind] = t; if(t.lensDraw) t.lensDraw(); refreshPenbar(); } /* board text tool */ function startBoardText(e){ if(board.querySelector(".wp-text-input")) return; const zf = VZ(); const r = board.getBoundingClientRect(); const x = (e.clientX - r.left)/zf, y = (e.clientY - r.top)/zf; const fontPx = PEN.size*6 + 12; const inp = document.createElement("input"); inp.type = "text"; inp.className = "wp-text-input"; inp.style.left = Math.min(x, board.clientWidth-150)+"px"; inp.style.top = Math.min(y, board.clientHeight-40)+"px"; inp.style.color = PEN.color; inp.style.fontSize = fontPx+"px"; inp.style.zIndex = 4700; board.appendChild(inp); setTimeout(()=>inp.focus(), 0); let done = false; const commit = ()=>{ if(done) return; done = true; const v = inp.value.trim(); if(v){ bCtx.globalCompositeOperation = "source-over"; bCtx.globalAlpha = 1; bCtx.fillStyle = PEN.color; bCtx.font = `700 ${fontPx*bDpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`; bCtx.textBaseline = "top"; bCtx.fillText(v, x*bDpr, y*bDpr); if(DTOOLS.lens) DTOOLS.lens.lensDraw(); } inp.remove(); }; inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); if(ev.key==="Escape"){ done = true; inp.remove(); } }); inp.addEventListener("blur", commit); } let bDraw = false, bx = 0, by = 0; const bPos = ev=>{ const r = bInk.getBoundingClientRect(); /* ratio-based: correct under any display zoom */ return [(ev.clientX-r.left)*(bInk.width/r.width), (ev.clientY-r.top)*(bInk.height/r.height)]; }; bInk.addEventListener("pointerdown", e=>{ if(e.pointerType==="mouse" && e.button!==0) return; e.preventDefault(); if(PEN.tool==="text"){ startBoardText(e); return; } bInk.setPointerCapture(e.pointerId); bDraw = true; PEN.nib = PEN.size*0.8; [bx,by] = bPos(e); bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType); }); bInk.addEventListener("pointermove", e=>{ if(!bDraw) return; (e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{ const [x,y] = bPos(ev); bStroke(x, y, ev.pressure, ev.pointerType); }); }); const bStop = ()=>{ if(bDraw) markBoardChange(); bDraw = false; bCtx.globalAlpha = 1; if(DTOOLS.lens) DTOOLS.lens.lensDraw(); }; bInk.addEventListener("pointerup", bStop); bInk.addEventListener("pointercancel", bStop); function bStroke(x,y,pressure,ptype){ bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over"; bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1; bCtx.strokeStyle = PEN.color; let w = PEN.size; if(PEN.tool==="erase") w = PEN.size*6; else if(PEN.tool==="mark") w = PEN.size*3.5; else if(PEN.tool==="vulp"){ let target; if(ptype==="pen" && pressure > 0){ target = PEN.size*(0.45 + pressure*1.9); }else{ const speed = Math.hypot(x-bx, y-by)/bDpr; target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12)); } PEN.nib += (target - PEN.nib)*0.3; w = PEN.nib; } bCtx.lineWidth = w*bDpr; bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke(); bx = x; by = y; } function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } } function setBoardInk(url){ bCtx.clearRect(0,0,bInk.width,bInk.height); if(!url) return; const im = new Image(); im.onload = ()=>bCtx.drawImage(im,0,0); im.src = url; } refreshPenbar(); /* gallery */ const galleryWrap = document.getElementById("galleryWrap"); const gallery = document.getElementById("gallery"); function renderGallery(){ gallery.innerHTML = ""; const query=(document.getElementById("gallerySearch").value||"").trim().toLowerCase(); const favOnly=document.getElementById("galleryFavOnly").classList.contains("on"); let favorites=[];try{favorites=JSON.parse(localStorage.getItem("teachWidgetFavorites")||"[]")}catch(e){} WIDGET_CATS.forEach(([cat,key])=>{ if(cat==="favorites"&&!favorites.length)return; if(cat!=="favorites"&&favOnly)return; let defs=cat==="favorites"?REGISTRY.filter(d=>favorites.includes(d.id)):REGISTRY.filter(d=>d.cat===cat); defs=defs.filter(d=>(T("wg_"+d.id)+" "+T("wg_"+d.id+"_d")).toLowerCase().includes(query)); if(!defs.length)return; const h=document.createElement("div");h.className="gcat-title";h.textContent=T(key);gallery.appendChild(h); const grid=document.createElement("div");grid.className="gallery"; defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0; const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon; const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id); const desc=document.createElement("div");desc.className="gd";desc.textContent=T("wg_"+def.id+"_d"); const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"β˜…":"β˜†";star.title=T("galleryFavorite"); star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()}; const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid); }); if(!gallery.children.length){const e=document.createElement("p");e.className="gallery-empty";e.textContent=T("galleryEmpty");gallery.append(e)} } galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); }); document.querySelector(".gallery-close").onclick=()=>galleryWrap.classList.remove("open"); const gallerySearch=document.getElementById("gallerySearch"),favOnlyBtn=document.getElementById("galleryFavOnly");gallerySearch.addEventListener("input",renderGallery);favOnlyBtn.onclick=()=>{favOnlyBtn.classList.toggle("on");renderGallery()}; const templates=[["templateMath","clock","numberline","fractions"],["templateLanguage","sentence","wordtypes","flits"],["templateDay","schedule","timer","mood"]]; function renderTemplates(){const host=document.getElementById("galleryTemplates");host.innerHTML="";templates.forEach(([key,...ids])=>host.append(btn(T(key),()=>{galleryWrap.classList.remove("open");ids.forEach((id,i)=>makeWidget(REGISTRY.find(d=>d.id===id),{x:24+i*45,y:24+i*38}))})));gallerySearch.placeholder=T("gallerySearch");gallerySearch.setAttribute("aria-label",T("gallerySearch"));favOnlyBtn.title=T("galleryFavorites");favOnlyBtn.setAttribute("aria-label",favOnlyBtn.title);document.getElementById("boardUndo").title=T("undo");document.getElementById("boardRedo").title=T("redo");document.getElementById("boardExport").textContent="⇩ "+T("exportBoard");document.getElementById("boardImport").textContent="⇧ "+T("importBoard");} const importInput=document.createElement("input");importInput.type="file";importInput.accept="application/json";importInput.hidden=true;document.body.append(importInput); document.getElementById("boardExport").onclick=()=>{const blob=new Blob([JSON.stringify(serializeBoard(),null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="teach-bord.json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)}; document.getElementById("boardImport").onclick=()=>importInput.click();importInput.onchange=()=>{const f=importInput.files[0];if(!f)return;if(f.size>2*1024*1024){alert(T("importError"));return}const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data||!Array.isArray(data.widgets)||data.widgets.length>100||(data.imgs||[]).length>50)throw Error();restoreBoard(data);markBoardChange()}catch(e){alert(T("importError"))}};r.readAsText(f);importInput.value=""}; function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave("boards")}} document.getElementById("boardUndo").onclick=()=>historyMove(false);document.getElementById("boardRedo").onclick=()=>historyMove(true);document.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.target.matches("input,textarea,[contenteditable]")){e.preventDefault();historyMove(e.shiftKey)}}); /* ---------- floating add-button (FAB) ---------- */ const fab = document.getElementById("fab"); const fabMenu = document.getElementById("fabmenu"); function refreshFab(){ fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets"); fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw"); fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind"); fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg"); fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen")); } fab.addEventListener("click", ()=>{ refreshFab(); fabMenu.classList.toggle("open"); const open = fabMenu.classList.contains("open"); fab.classList.toggle("open", open); if(open) document.getElementById("boardStrip").classList.remove("open"); }); document.addEventListener("pointerdown", e=>{ if(!fabMenu.classList.contains("open")) return; if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return; fabMenu.classList.remove("open"); fab.classList.remove("open"); }); fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); renderTemplates(); renderGallery(); galleryWrap.classList.add("open"); }); fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); setPen(!document.body.classList.contains("pen")); }); fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); const def = REGISTRY.find(d=>d.id==="mind"); makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); }); /* image upload onto the board */ const imgInput = document.createElement("input"); imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none"; document.body.appendChild(imgInput); fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); imgInput.click(); }); function loadImgFile(f, x, y){ const keepPng = /png|gif|svg/.test(f.type); const img = new Image(); img.onload = ()=>{ const c = document.createElement("canvas"); const s = Math.min(1, 900/Math.max(img.width, img.height)); c.width = Math.round(img.width*s); c.height = Math.round(img.height*s); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width)); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(f); } imgInput.addEventListener("change", ()=>{ const f = imgInput.files[0]; if(!f) return; loadImgFile(f, 80, 90); imgInput.value = ""; }); /* drag & drop images from other websites or the computer onto the board */ board.addEventListener("dragover", e=>{ e.preventDefault(); }); board.addEventListener("drop", e=>{ e.preventDefault(); const zf = VZ(); const r = board.getBoundingClientRect(); const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70); const dt = e.dataTransfer; if(dt.files && dt.files.length){ [...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); }); return; } let url = null; const html = dt.getData("text/html"); if(html){ const m = html.match(/]+src\s*=\s*["']([^"']+)["']/i); if(m) url = m[1]; } if(!url){ const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0]; if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri; else if(/^data:image\//.test(uri)) url = uri; else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */ } if(url) addBoardImage(url, x, y, 320); }); function addBoardImage(src, x, y, w){ const d = document.createElement("div"); d.className = "bimg"; d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px"; const im = document.createElement("img"); im.src = src; im.draggable = false; d.appendChild(im); const xb = document.createElement("button"); xb.className = "dtool-x"; xb.textContent = "βœ•"; xb.addEventListener("click", ()=>{ d.remove(); markBoardChange(); }); d.appendChild(xb); const rs = document.createElement("div"); rs.className = "bimg-rs"; rs.addEventListener("pointerdown", e=>{ e.preventDefault(); e.stopPropagation(); const zf = VZ(); const sw = d.offsetWidth, sx = e.clientX; const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); d.appendChild(rs); d.addEventListener("pointerdown", e=>{ if(e.target===xb || e.target===rs) return; if(e.pointerType==="mouse" && e.button!==0) return; e.preventDefault(); const zf = VZ(); const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop; const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); board.appendChild(d); markBoardChange(); return d; } document.addEventListener("langchange", refreshFab); /* ========================================================= Multiple boards, organised in folders (quick nav next to +) ==========================================================*/ /* stabiel board-id, onafhankelijk van naam/positie - nodig zodat een toewijzing aan een klas/leerling (zie pupil.js) een bord blijft vinden ook als het wordt hernoemd of andere borden ervoor/erna worden toegevoegd/verwijderd */ function genBoardId(){ return "b" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); } function newBoardEntry(name){ return { id: genBoardId(), name, data:null }; } function freshBS(){ return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", boards:[newBoardEntry(LANG==="nl" ? "Bord 1" : "Board 1")] }], f:0, b:0 }; } let BS = freshBS(); function boardsFromData(data){ let bs; if(data && data.boards && data.boards.folders && data.boards.folders.length) bs = data.boards; else if(data && data.board){ bs = freshBS(); bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ } else bs = freshBS(); /* borden opgeslagen vΓ³Γ³r dit id-systeem bestond krijgen er hier alsnog één */ bs.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(!bo.id) bo.id = genBoardId(); })); return bs; } function curSlot(){ if(BS.f >= BS.folders.length) BS.f = 0; let fo = BS.folders[BS.f]; /* lege mappen bestaan sinds de verkenner: wijs dan uit naar een map mΓ©t borden, of geef de actieve lege map alsnog een vers bord */ if(!fo.boards.length){ const fi = BS.folders.findIndex(f=>f.boards.length); if(fi >= 0){ BS.f = fi; fo = BS.folders[BS.f]; } else fo.boards.push(newBoardEntry(T("boardName")+" 1")); } if(BS.b >= fo.boards.length) BS.b = 0; return fo.boards[BS.b]; } function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; } function stashCurrent(){ curSlot().data = serializeBoard(); } function loadCurrentSlot(){ restoreBoard(curSlot().data || emptyBoardData()); updateBoardsUI(); } function switchTo(f, b){ stashCurrent(); markBoardChange(); BS.f = f; BS.b = b; loadCurrentSlot(); } const btnFolders = document.getElementById("btnFolders"); btnFolders.addEventListener("click", ()=>{ stashCurrent(); /* verse thumbnails, ook van het actieve bord */ openExplorer(boardsExplorerAdapter()); }); function updateBoardsUI(){ btnFolders.title = `${T("boardsNav")} Β· ${BS.folders[BS.f].name} / ${curSlot().name}`; fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; if(boardStrip.classList.contains("open")) renderStrip(); } /* ---- verkenner-adapter: virtuele boom over de platte BS.folders-lijst; nesting zit in de mapnaam als pad met "/"-scheiding ("Rekenen/Groep 4"), dus bestaande data en de server (findBoardById op bord-id) blijven werken */ const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean); const joinBoardPath = p => p.join("/"); function boardsExplorerAdapter(){ const norm = f => joinBoardPath(splitBoardPath(f.name)); const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path)); const findBoard = id => { for(let fi=0; fib.id===id); if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] }; } return null; }; const activeId = ()=>curSlot().id; /* indices herstellen nadat entries verschoven/verwijderd zijn */ const relocate = (id)=>{ const hit = findBoard(id); if(hit){ BS.f = hit.fi; BS.b = hit.bi; } }; const mutated = ()=>{ markBoardChange(); updateBoardsUI(); }; return { title: ()=>T("boardsNav"), allowRootItems: false, newItemLabel: ()=>T("newBoard"), list(path){ const p = joinBoardPath(path); const folderMap = new Map(); let items = []; BS.folders.forEach((f, fi)=>{ const j = norm(f); if(j === p){ items = f.boards.map((bo, bi)=>({ key: bo.id, name: bo.name, active: fi===BS.f && bi===BS.b, tile: ()=>boardThumb((fi===BS.f && bi===BS.b) ? serializeBoard() : bo.data), })); }else if(!p || j.startsWith(p + "/")){ const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; if(seg) folderMap.set(seg, (folderMap.get(seg)||0) + f.boards.length); } }); const folders = [...folderMap].map(([name, count])=>({name, count})) .sort((a,b)=>a.name.localeCompare(b.name)); return { folders, items }; }, createFolder(path, name){ const full = [...path, name]; if(entryAt(full) || this.list(path).folders.some(f=>f.name===name)) return T("exExists"); BS.folders.push({ name: joinBoardPath(full), boards: [] }); mutated(); return null; }, renameFolder(path, oldName, newName){ return this.moveFolder([...path, oldName], path, newName); }, moveFolder(srcPath, targetPath, newName){ const from = joinBoardPath(srcPath); const to = joinBoardPath([...targetPath, newName || srcPath[srcPath.length-1]]); if(from === to) return null; if(BS.folders.some(f=>{ const j = norm(f); return j === to || j.startsWith(to + "/"); })) return T("exExists"); const id = activeId(); BS.folders.forEach(f=>{ const j = norm(f); if(j === from || j.startsWith(from + "/")) f.name = to + j.slice(from.length); }); relocate(id); mutated(); return null; }, deleteFolder(path, name){ const full = joinBoardPath([...path, name]); const under = BS.folders.filter(f=>{ const j = norm(f); return j === full || j.startsWith(full + "/"); }); if(under.some(f=>f.boards.length)) return T("exFolderNotEmpty"); const id = activeId(); BS.folders = BS.folders.filter(f=>!under.includes(f)); if(!BS.folders.length) BS.folders.push({ name: T("folderName")+" 1", boards: [] }); relocate(id); mutated(); return null; }, createItem(path){ let entry = entryAt(path); if(!entry){ entry = { name: joinBoardPath(path), boards: [] }; BS.folders.push(entry); } entry.boards.push(newBoardEntry(`${T("boardName")} ${entry.boards.length+1}`)); mutated(); return null; }, renameItem(id, name){ const hit = findBoard(id); if(!hit) return null; hit.bo.name = name; mutated(); return null; }, moveItem(id, targetPath){ const hit = findBoard(id); if(!hit) return null; let entry = entryAt(targetPath); if(!entry){ entry = { name: joinBoardPath(targetPath), boards: [] }; BS.folders.push(entry); } if(entry === hit.fo) return null; const act = activeId(); hit.fo.boards.splice(hit.bi, 1); entry.boards.push(hit.bo); relocate(act); mutated(); return null; }, deleteItem(id){ const hit = findBoard(id); if(!hit) return null; if(flatBoards().length <= 1) return T("exLastBoard"); const wasActive = id === activeId(); hit.fo.boards.splice(hit.bi, 1); if(wasActive){ BS.f = 0; BS.b = 0; loadCurrentSlot(); } else relocate(activeId()); mutated(); return null; }, openItem(id){ const hit = findBoard(id); if(hit && !(hit.fi===BS.f && hit.bi===BS.b)) switchTo(hit.fi, hit.bi); }, }; } function flatBoards(){ const out = []; BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo}))); return out; } /* ---- quick switch: hover the πŸ—‚ button β†’ previews side by side ---- */ const fabBoardsBtn = document.getElementById("fabBoardsBtn"); const boardStrip = document.getElementById("boardStrip"); let stripTimer = null; function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); } function closeStrip(){ boardStrip.classList.remove("open"); } fabBoardsBtn.addEventListener("click", ()=>{ boardStrip.classList.contains("open") ? closeStrip() : openStrip(); }); fabBoardsBtn.addEventListener("mouseenter", openStrip); fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer)); boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); document.addEventListener("pointerdown", e=>{ if(!boardStrip.classList.contains("open")) return; if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return; closeStrip(); }); boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; }); /* mini preview of a board's contents */ function boardThumb(data){ const w = 168, h = 105; const c = document.createElement("canvas"); c.width = w; c.height = h; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); if(!data) return c; const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1); (data.imgs||[]).forEach(im=>{ g.fillStyle = "#d8e2ef"; g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh)); }); (data.widgets||[]).forEach(wd=>{ const x = wd.x*w/bw, y = wd.y*h/bh, ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh); g.fillStyle = "rgba(59,125,216,.14)"; g.fillRect(x, y, ww, wh); g.strokeStyle = "rgba(59,125,216,.55)"; g.strokeRect(x, y, ww, wh); const def = REGISTRY.find(d=>d.id===wd.id); if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); } }); if(data.ink){ const img = new Image(); img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} }; img.src = data.ink; } return c; } function renderStrip(){ boardStrip.innerHTML = ""; const items = flatBoards(); items.forEach(it=>{ const isCur = it.fi===BS.f && it.bi===BS.b; const card = document.createElement("div"); card.className = "bs-card" + (isCur ? " active" : ""); card.title = `${it.fo.name} / ${it.bo.name}`; card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data)); const row = document.createElement("div"); row.className = "bs-name"; const nm = document.createElement("span"); nm.textContent = it.bo.name; row.appendChild(nm); if(items.length > 1){ const x = document.createElement("button"); x.className = "bs-x"; x.textContent = "βœ•"; x.title = T("delBoard"); x.addEventListener("pointerdown", ev=>ev.stopPropagation()); x.addEventListener("click", ev=>{ ev.stopPropagation(); if(!x.dataset.arm){ /* first click arms, second click deletes */ clearTimeout(stripTimer); /* keep the strip open while confirming */ x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "βœ“?"; setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="βœ•"; }, 3000); return; } const fo = BS.folders[it.fi]; fo.boards.splice(it.bi, 1); if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } else if(it.fi===BS.f && it.bi < BS.b) BS.b--; markBoardChange(); renderStrip(); }); row.appendChild(x); } card.appendChild(row); card.addEventListener("click", ()=>{ if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); } }); boardStrip.appendChild(card); }); const add = document.createElement("div"); add.className = "bs-card bs-new"; add.textContent = "οΌ‹"; add.title = T("newBoard"); add.addEventListener("click", ()=>{ const fo = BS.folders[BS.f]; fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`)); switchTo(BS.f, fo.boards.length-1); renderStrip(); }); boardStrip.appendChild(add); } document.addEventListener("langchange", updateBoardsUI);