/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */ /* ========================================================= Widget framework ==========================================================*/ const board = document.getElementById("board"); const dock = document.getElementById("dock"); let zTop = 10, widgetCount = 0; const instances = new Set(); const REGISTRY = [ { id:"letters", cat:"taal", icon:"πŸ”€", w:760, h:640, minW:640, minH:540, mount:mountLetterGame }, { id:"flits", cat:"taal", icon:"⚑", w:520, h:430, minW:400, minH:330, mount:mountFlits }, { id:"hak", cat:"taal", icon:"βœ‚οΈ", w:540, h:440, minW:420, minH:340, mount:mountHak }, { id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall }, { id:"anchor", cat:"taal", icon:"βš“", w:680, h:500, minW:440, minH:340, mount:mountAnchor }, { id:"madd", cat:"rekenen", icon:"βž•", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") }, { id:"msub", cat:"rekenen", icon:"βž–", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") }, { id:"mmul", cat:"rekenen", icon:"βœ–οΈ", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") }, { id:"mdiv", cat:"rekenen", icon:"βž—", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, { id:"mpct", cat:"rekenen", icon:"πŸ’―", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"mind", cat:"tools", icon:"πŸ•ΈοΈ", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"notes", cat:"tools", icon:"πŸ“", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; /* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */ function clampWidgetSize(w, h, def){ const fw = Math.max(def.minW || 240, Math.min(w, board.clientWidth)); const fh = Math.max(def.minH || 180, Math.min(h, board.clientHeight)); return { w: fw, h: fh }; } function makeWidget(def, saved){ const win = document.createElement("div"); win.className = "widget"; const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def); win.style.width = size.w+"px"; win.style.height = size.h+"px"; if(def.minW) win.style.minWidth = def.minW+"px"; if(def.minH) win.style.minHeight = def.minH+"px"; const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240; const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160; win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px"; win.style.top = Math.max(0, Math.min(cy, board.clientHeight - size.h - 8)) + "px"; win.style.zIndex = ++zTop; widgetCount++; if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } const head = document.createElement("div"); head.className = "widget-head"; head.innerHTML = `${def.icon}`; const zBtn = document.createElement("button"); zBtn.className = "wmin wz"; zBtn.textContent = "AοΌ‹"; head.appendChild(zBtn); const minBtn = document.createElement("button"); minBtn.className = "wmin"; minBtn.textContent = "–"; head.appendChild(minBtn); const maxBtn = document.createElement("button"); maxBtn.className = "wmin"; maxBtn.textContent = "β–’"; head.appendChild(maxBtn); const close = document.createElement("button"); close.className = "wclose"; close.textContent = "βœ•"; head.appendChild(close); const body = document.createElement("div"); body.className = "widget-body"; win.appendChild(head); win.appendChild(body); /* eigen resize-greep (touch-vriendelijk, vervangt de native browser-grip) */ const rs = document.createElement("div"); rs.className = "widget-rs"; rs.addEventListener("pointerdown", e=>{ e.preventDefault(); e.stopPropagation(); win.style.zIndex = ++zTop; const zf = VZ(); const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY; const mv = ev=>{ const maxW = board.clientWidth - win.offsetLeft - 8; const maxH = board.clientHeight - win.offsetTop - 8; let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf; nw = Math.max(def.minW || 240, Math.min(nw, maxW)); nh = Math.max(def.minH || 180, Math.min(nh, maxH)); win.style.width = nw+"px"; win.style.height = nh+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); win.appendChild(rs); let pill = null; function setMin(m){ if(m && !pill){ win.dataset.prevW = win.offsetWidth; win.dataset.prevH = win.offsetHeight; win.dataset.min = "1"; win.style.display = "none"; pill = document.createElement("button"); pill.className = "dock-item"; pill.innerHTML = `${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)); /* per-widget content zoom for low-vision pupils */ let wz = (saved && saved.wz) || 1; function applyWZ(){ body.style.zoom = wz; win.dataset.wz = wz; zBtn.textContent = wz===1 ? "AοΌ‹" : Math.round(wz*100)+"%"; zBtn.classList.toggle("on", wz!==1); zBtn.title = T("wZoom"); } zBtn.addEventListener("click", ()=>{ wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; applyWZ(); }); applyWZ(); function setMax(m){ if(m && win.dataset.max!=="1"){ win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop; win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight; win.dataset.max = "1"; win.style.left = "0px"; win.style.top = "0px"; win.style.width = board.clientWidth+"px"; win.style.height = board.clientHeight+"px"; win.style.zIndex = ++zTop; maxBtn.textContent = "❐"; setHudMin(true); /* keep the tools bar out of the way */ }else if(!m && win.dataset.max==="1"){ win.dataset.max = ""; win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px"; win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px"; maxBtn.textContent = "β–’"; } maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); } maxBtn.addEventListener("click", ()=>setMax(win.dataset.max!=="1")); head.addEventListener("dblclick", e=>{ if(e.target===close || e.target===minBtn || e.target===maxBtn) return; setMin(true); }); const setTitle = ()=>{ head.querySelector(".wtitle").textContent = T("wg_"+def.id); minBtn.title = T("wMin"); maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); zBtn.title = T("wZoom"); if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); } }; setTitle(); document.addEventListener("langchange", setTitle); win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); head.addEventListener("pointerdown", e=>{ if(e.target===close) return; e.preventDefault(); const zf = VZ(); const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop; const move = ev=>{ win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px"; win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px"; }; const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); }); board.appendChild(win); const api = def.mount(body, saved ? saved.state : null) || {}; if(saved && saved.min) setMin(true); if(saved && saved.max) setMax(true); const inst = { def, win, api }; instances.add(inst); close.addEventListener("click", ()=>{ if(pill){ pill.remove(); pill = null; } instances.delete(inst); win.remove(); updateEmptyHint(); }); updateEmptyHint(); return inst; } function updateEmptyHint(){ document.body.classList.toggle("board-empty", !board.querySelector(".widget")); } function serializeBoard(){ return { ink: boardInkURL(), bg: BOARD.bg, widgets: [...instances].map(inst=>{ const w = inst.win; const min = w.dataset.min === "1"; const max = w.dataset.max === "1"; return { id: inst.def.id, x: max ? +w.dataset.pmx : w.offsetLeft, y: max ? +w.dataset.pmy : w.offsetTop, w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth, h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight, min, max, wz: +w.dataset.wz || 1, bare: w.dataset.bare === "1", state: inst.api.getState ? inst.api.getState() : null }; }), imgs: [...board.querySelectorAll(".bimg")].map(b=>({ src: b.querySelector("img").src, x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth })) }; } function restoreBoard(b){ [...instances].forEach(inst=>inst.win.remove()); instances.clear(); dock.innerHTML = ""; widgetCount = 0; (b.widgets||[]).forEach(wd=>{ const def = REGISTRY.find(d=>d.id===wd.id); if(def) makeWidget(def, wd); }); setBoardInk(b.ink); setBoardBg(b.bg || "dots"); board.querySelectorAll(".bimg").forEach(x=>x.remove()); (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); updateEmptyHint(); } /* ---------- board drawing layer (pen mode) ---------- */ const bInk = document.createElement("canvas"); bInk.id = "boardInk"; board.insertBefore(bInk, board.firstChild); const bCtx = bInk.getContext("2d"); const bDpr = window.devicePixelRatio || 1; function bResize(){ const w = board.clientWidth, h = board.clientHeight; if(w<2 || h<2) return; const keep = document.createElement("canvas"); keep.width = bInk.width; keep.height = bInk.height; if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0); bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr); if(keep.width) bCtx.drawImage(keep,0,0); bCtx.lineCap = "round"; bCtx.lineJoin = "round"; } new ResizeObserver(bResize).observe(board); bResize(); const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 }; const BOARD = { bg:"blank" }; const penbar = document.getElementById("penbar"); function setPen(on){ document.body.classList.toggle("pen", on); document.body.classList.toggle("pen-text", on && PEN.tool==="text"); refreshPenbar(); } function setBoardBg(bg){ BOARD.bg = bg; board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; refreshPenbar(); } const PEN_TOOLS = [["hand","βœ‹","toolHand"],["pen","✏️","toolPen"],["vulp","πŸ–‹οΈ","toolFountain"],["mark","πŸ–οΈ","toolMark"],["text","πŸ”€","toolText"],["erase","🧽","eraser"]]; PEN_TOOLS.forEach(([id,icon])=>{ const b = document.createElement("button"); b.dataset.tool = id; b.textContent = icon; b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); refreshPenbar(); }); penbar.querySelector(".pen-tools").appendChild(b); }); ["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"].forEach(c=>{ const b = document.createElement("button"); b.className = "wp-color"; b.dataset.c = c; const dot = document.createElement("i"); dot.style.background = c; b.appendChild(dot); b.addEventListener("click", ()=>{ PEN.color = c; if(PEN.tool==="erase") PEN.tool = "pen"; refreshPenbar(); }); penbar.querySelector(".pen-colors").appendChild(b); }); [3,5,8].forEach(s=>{ const b = document.createElement("button"); b.className = "wp-size"; b.dataset.s = s; const i = document.createElement("i"); i.style.width = i.style.height = (s+4)+"px"; b.appendChild(i); b.addEventListener("click", ()=>{ PEN.size = s; refreshPenbar(); }); penbar.querySelector(".pen-sizes").appendChild(b); }); const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]]; PEN_BGS.forEach(([id])=>{ const b = document.createElement("button"); b.className = "wp-bg"; b.dataset.bg = id; b.appendChild(document.createElement("i")); b.addEventListener("click", ()=>setBoardBg(id)); penbar.querySelector(".pen-bgs").appendChild(b); }); const PEN_EXTRAS = [["ruler","πŸ“","toolRuler"],["geo","πŸ“","toolGeo"],["lens","πŸ”","toolLens"],["light","🚦","wg_light"]]; PEN_EXTRAS.forEach(([id,icon])=>{ const b = document.createElement("button"); b.dataset.extra = id; b.textContent = icon; b.addEventListener("click", ()=>toggleDTool(id)); penbar.querySelector(".pen-extras").appendChild(b); }); penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height)); /* the ✍-button toggles drawing mode on/off (palette itself stays visible) */ penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen"))); /* orientation + collapse of the whole bar */ document.querySelector(".hud-orient").addEventListener("click", ()=>{ document.getElementById("hud").classList.toggle("vert"); }); document.querySelector(".hud-min").addEventListener("click", function(){ setHudMin(!document.getElementById("hud").classList.contains("min")); }); /* unified draggable bar (pen palette + dock) */ const hud = document.getElementById("hud"); hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{ e.preventDefault(); const zf = VZ(); const r = hud.getBoundingClientRect(); hud.style.transform = "none"; hud.style.bottom = "auto"; hud.style.left = (r.left/zf)+"px"; hud.style.top = (r.top/zf)+"px"; const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf; const mv = ev=>{ hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px"; hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); function setHudMin(m){ hud.classList.toggle("min", m); const b = document.querySelector(".hud-min"); if(b) b.textContent = m ? "οΌ‹" : "–"; } function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); } new MutationObserver(updateHud).observe(dock, {childList:true}); updateHud(); function refreshPenbar(){ penbar.querySelectorAll(".pen-tools button").forEach(b=>{ b.classList.toggle("on", b.dataset.tool===PEN.tool); b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]); }); penbar.querySelectorAll(".wp-color").forEach(b=> b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase")); penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size)); penbar.querySelectorAll(".wp-bg").forEach(b=>{ b.classList.toggle("on", b.dataset.bg===BOARD.bg); b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]); }); penbar.querySelectorAll(".pen-extras button").forEach(b=>{ b.classList.toggle("on", !!DTOOLS[b.dataset.extra]); b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]); }); penbar.querySelector(".pen-clear").title = T("clearAll"); const power = penbar.querySelector(".pen-close"); const penOn = document.body.classList.contains("pen"); power.textContent = "✍"; power.title = T("penMode"); power.classList.toggle("on", penOn); document.querySelector(".hud-orient").title = T("hudOrient"); document.querySelector(".hud-min").title = T("hudMin"); document.body.classList.toggle("pen-text", penOn && PEN.tool==="text"); document.body.classList.toggle("pen-hand", PEN.tool==="hand"); } document.addEventListener("langchange", refreshPenbar); /* ---------- physical tools: ruler, set square, magnifier ---------- */ const DTOOLS = {}; function makeMovable(t){ let ang = 0; t.addEventListener("pointerdown", e=>{ if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return; e.preventDefault(); e.stopPropagation(); if(e.target.classList.contains("dtool-rot")){ const r = t.getBoundingClientRect(); const cx = r.left+r.width/2, cy = r.top+r.height/2; const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang; const mv = ev=>{ ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start; t.style.transform = `rotate(${ang}deg)`; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); return; } const zf = VZ(); const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop; const mv = ev=>{ t.style.left = (ev.clientX/zf-sx)+"px"; t.style.top = (ev.clientY/zf-sy)+"px"; if(t.lensDraw) t.lensDraw(); }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); } function toggleDTool(kind){ if(DTOOLS[kind]){ if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv); DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return; } const t = document.createElement("div"); t.className = "dtool dtool-"+kind; if(kind==="ruler"){ t._len = 600; t._hc = false; const drawRuler = ()=>{ const L = t._len, hc = t._hc; const ink = hc ? "#000" : "#5a4a20"; const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)"; let ticks = ""; for(let i=5; i`; 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 = ()=>{ bDraw = false; bCtx.globalAlpha = 1; if(DTOOLS.lens) DTOOLS.lens.lensDraw(); }; bInk.addEventListener("pointerup", bStop); bInk.addEventListener("pointercancel", bStop); function bStroke(x,y,pressure,ptype){ bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over"; bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1; bCtx.strokeStyle = PEN.color; let w = PEN.size; if(PEN.tool==="erase") w = PEN.size*6; else if(PEN.tool==="mark") w = PEN.size*3.5; else if(PEN.tool==="vulp"){ let target; if(ptype==="pen" && pressure > 0){ target = PEN.size*(0.45 + pressure*1.9); }else{ const speed = Math.hypot(x-bx, y-by)/bDpr; target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12)); } PEN.nib += (target - PEN.nib)*0.3; w = PEN.nib; } bCtx.lineWidth = w*bDpr; bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke(); bx = x; by = y; } function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } } function setBoardInk(url){ bCtx.clearRect(0,0,bInk.width,bInk.height); if(!url) return; const im = new Image(); im.onload = ()=>bCtx.drawImage(im,0,0); im.src = url; } refreshPenbar(); /* gallery */ const galleryWrap = document.getElementById("galleryWrap"); const gallery = document.getElementById("gallery"); function renderGallery(){ gallery.innerHTML = ""; WIDGET_CATS.forEach(([cat,key])=>{ const defs = REGISTRY.filter(d=>d.cat===cat); if(!defs.length) return; const h = document.createElement("div"); h.className = "gcat-title"; h.textContent = T(key); gallery.appendChild(h); const grid = document.createElement("div"); grid.className = "gallery"; defs.forEach(def=>{ const c = document.createElement("div"); c.className = "gcard"; c.innerHTML = `
${def.icon}
${T("wg_"+def.id)}
${T("wg_"+def.id+"_d")}
`; c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); }); grid.appendChild(c); }); gallery.appendChild(grid); }); } galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); }); /* ---------- floating add-button (FAB) ---------- */ const fab = document.getElementById("fab"); const fabMenu = document.getElementById("fabmenu"); function refreshFab(){ fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets"); fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw"); fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind"); fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg"); fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen")); } fab.addEventListener("click", ()=>{ refreshFab(); fabMenu.classList.toggle("open"); const open = fabMenu.classList.contains("open"); fab.classList.toggle("open", open); if(open) document.getElementById("boardStrip").classList.remove("open"); }); document.addEventListener("pointerdown", e=>{ if(!fabMenu.classList.contains("open")) return; if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return; fabMenu.classList.remove("open"); fab.classList.remove("open"); }); fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); renderGallery(); galleryWrap.classList.add("open"); }); fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); setPen(!document.body.classList.contains("pen")); }); fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); const def = REGISTRY.find(d=>d.id==="mind"); makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); }); /* image upload onto the board */ const imgInput = document.createElement("input"); imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none"; document.body.appendChild(imgInput); fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); imgInput.click(); }); function loadImgFile(f, x, y){ const keepPng = /png|gif|svg/.test(f.type); const img = new Image(); img.onload = ()=>{ const c = document.createElement("canvas"); const s = Math.min(1, 900/Math.max(img.width, img.height)); c.width = Math.round(img.width*s); c.height = Math.round(img.height*s); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width)); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(f); } imgInput.addEventListener("change", ()=>{ const f = imgInput.files[0]; if(!f) return; loadImgFile(f, 80, 90); imgInput.value = ""; }); /* drag & drop images from other websites or the computer onto the board */ board.addEventListener("dragover", e=>{ e.preventDefault(); }); board.addEventListener("drop", e=>{ e.preventDefault(); const zf = VZ(); const r = board.getBoundingClientRect(); const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70); const dt = e.dataTransfer; if(dt.files && dt.files.length){ [...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); }); return; } let url = null; const html = dt.getData("text/html"); if(html){ const m = html.match(/]+src\s*=\s*["']([^"']+)["']/i); if(m) url = m[1]; } if(!url){ const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0]; if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri; else if(/^data:image\//.test(uri)) url = uri; else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */ } if(url) addBoardImage(url, x, y, 320); }); function addBoardImage(src, x, y, w){ const d = document.createElement("div"); d.className = "bimg"; d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px"; const im = document.createElement("img"); im.src = src; im.draggable = false; d.appendChild(im); const xb = document.createElement("button"); xb.className = "dtool-x"; xb.textContent = "βœ•"; xb.addEventListener("click", ()=>d.remove()); d.appendChild(xb); const rs = document.createElement("div"); rs.className = "bimg-rs"; rs.addEventListener("pointerdown", e=>{ e.preventDefault(); e.stopPropagation(); const zf = VZ(); const sw = d.offsetWidth, sx = e.clientX; const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); d.appendChild(rs); d.addEventListener("pointerdown", e=>{ if(e.target===xb || e.target===rs) return; if(e.pointerType==="mouse" && e.button!==0) return; e.preventDefault(); const zf = VZ(); const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop; const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); }); board.appendChild(d); return d; } document.addEventListener("langchange", refreshFab); /* ========================================================= Multiple boards, organised in folders (quick nav next to +) ==========================================================*/ function freshBS(){ return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }], f:0, b:0 }; } let BS = freshBS(); function boardsFromData(data){ if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards; if(data && data.board){ const bs = freshBS(); bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ return bs; } return freshBS(); } function curSlot(){ if(BS.f >= BS.folders.length) BS.f = 0; const fo = BS.folders[BS.f]; if(BS.b >= fo.boards.length) BS.b = 0; return fo.boards[BS.b]; } function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; } function stashCurrent(){ curSlot().data = serializeBoard(); } function loadCurrentSlot(){ restoreBoard(curSlot().data || emptyBoardData()); updateBoardsUI(); } function switchTo(f, b){ stashCurrent(); BS.f = f; BS.b = b; loadCurrentSlot(); } const boardsPanel = document.getElementById("boardsPanel"); const btnFolders = document.getElementById("btnFolders"); btnFolders.addEventListener("click", ()=>{ renderBoardsPanel(); boardsPanel.classList.toggle("open"); }); document.addEventListener("pointerdown", e=>{ if(!boardsPanel.classList.contains("open")) return; if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return; boardsPanel.classList.remove("open"); }); function updateBoardsUI(){ btnFolders.title = `${T("boardsNav")} Β· ${BS.folders[BS.f].name} / ${curSlot().name}`; fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; if(boardsPanel.classList.contains("open")) renderBoardsPanel(); if(boardStrip.classList.contains("open")) renderStrip(); } function flatBoards(){ const out = []; BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo}))); return out; } /* ---- quick switch: hover the πŸ—‚ button β†’ previews side by side ---- */ const fabBoardsBtn = document.getElementById("fabBoardsBtn"); const boardStrip = document.getElementById("boardStrip"); let stripTimer = null; function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); } function closeStrip(){ boardStrip.classList.remove("open"); } fabBoardsBtn.addEventListener("click", ()=>{ boardStrip.classList.contains("open") ? closeStrip() : openStrip(); }); fabBoardsBtn.addEventListener("mouseenter", openStrip); fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer)); boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); document.addEventListener("pointerdown", e=>{ if(!boardStrip.classList.contains("open")) return; if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return; closeStrip(); }); boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; }); /* mini preview of a board's contents */ function boardThumb(data){ const w = 168, h = 105; const c = document.createElement("canvas"); c.width = w; c.height = h; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); if(!data) return c; const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1); (data.imgs||[]).forEach(im=>{ g.fillStyle = "#d8e2ef"; g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh)); }); (data.widgets||[]).forEach(wd=>{ const x = wd.x*w/bw, y = wd.y*h/bh, ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh); g.fillStyle = "rgba(59,125,216,.14)"; g.fillRect(x, y, ww, wh); g.strokeStyle = "rgba(59,125,216,.55)"; g.strokeRect(x, y, ww, wh); const def = REGISTRY.find(d=>d.id===wd.id); if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); } }); if(data.ink){ const img = new Image(); img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} }; img.src = data.ink; } return c; } function renderStrip(){ boardStrip.innerHTML = ""; const items = flatBoards(); items.forEach(it=>{ const isCur = it.fi===BS.f && it.bi===BS.b; const card = document.createElement("div"); card.className = "bs-card" + (isCur ? " active" : ""); card.title = `${it.fo.name} / ${it.bo.name}`; card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data)); const row = document.createElement("div"); row.className = "bs-name"; const nm = document.createElement("span"); nm.textContent = it.bo.name; row.appendChild(nm); if(items.length > 1){ const x = document.createElement("button"); x.className = "bs-x"; x.textContent = "βœ•"; x.title = T("delBoard"); x.addEventListener("pointerdown", ev=>ev.stopPropagation()); x.addEventListener("click", ev=>{ ev.stopPropagation(); if(!x.dataset.arm){ /* first click arms, second click deletes */ clearTimeout(stripTimer); /* keep the strip open while confirming */ x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "βœ“?"; setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="βœ•"; }, 3000); return; } const fo = BS.folders[it.fi]; fo.boards.splice(it.bi, 1); if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } else if(it.fi===BS.f && it.bi < BS.b) BS.b--; renderStrip(); }); row.appendChild(x); } card.appendChild(row); card.addEventListener("click", ()=>{ if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); } }); boardStrip.appendChild(card); }); const add = document.createElement("div"); add.className = "bs-card bs-new"; add.textContent = "οΌ‹"; add.title = T("newBoard"); add.addEventListener("click", ()=>{ const fo = BS.folders[BS.f]; fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); switchTo(BS.f, fo.boards.length-1); renderStrip(); }); boardStrip.appendChild(add); } function renderBoardsPanel(){ boardsPanel.innerHTML = ""; BS.folders.forEach((fo, fi)=>{ const fr = document.createElement("div"); fr.className = "bp-folder"; const nm = document.createElement("span"); nm.className = "bpf-name"; nm.textContent = "πŸ“ " + fo.name; fr.appendChild(nm); const add = document.createElement("button"); add.textContent = T("newBoard"); add.addEventListener("click", ()=>{ fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); switchTo(fi, fo.boards.length-1); renderBoardsPanel(); }); fr.appendChild(add); if(BS.folders.length > 1){ const del = document.createElement("button"); del.textContent = "πŸ—‘"; del.addEventListener("click", ()=>{ if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); } else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; } renderBoardsPanel(); }); fr.appendChild(del); } boardsPanel.appendChild(fr); const row = document.createElement("div"); row.className = "bp-boards"; fo.boards.forEach((bo, bi)=>{ const chip = document.createElement("div"); chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : ""); const lbl = document.createElement("span"); lbl.textContent = bo.name; chip.appendChild(lbl); chip.addEventListener("click", e=>{ if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return; if(fi===BS.f && bi===BS.b) return; switchTo(fi, bi); renderBoardsPanel(); }); chip.addEventListener("dblclick", ()=>{ if(chip.querySelector("input")) return; const inp = document.createElement("input"); inp.value = bo.name; inp.maxLength = 20; chip.replaceChild(inp, lbl); inp.focus(); inp.select(); let done = false; const commit = ()=>{ if(done) return; done = true; bo.name = inp.value.trim() || bo.name; renderBoardsPanel(); updateBoardsUI(); }; inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); inp.addEventListener("blur", commit); inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); }); if(fo.boards.length > 1 || BS.folders.length > 1){ const x = document.createElement("button"); x.className = "bpb-x"; x.textContent = "βœ•"; x.addEventListener("click", ev=>{ ev.stopPropagation(); const wasCur = (fi===BS.f && bi===BS.b); fo.boards.splice(bi,1); if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); if(wasCur){ BS.b = 0; loadCurrentSlot(); } else if(fi===BS.f && bi < BS.b) BS.b--; renderBoardsPanel(); }); chip.appendChild(x); } row.appendChild(chip); }); boardsPanel.appendChild(row); }); const nf = document.createElement("div"); nf.className = "bp-new"; const inp = document.createElement("input"); inp.placeholder = T("folderPh"); inp.maxLength = 20; const addFolder = ()=>{ const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; BS.folders.push({ name, boards:[{ name:T("boardName")+" 1", data:null }] }); inp.value = ""; renderBoardsPanel(); }; inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); const nb = document.createElement("button"); nb.className = "tbtn"; nb.textContent = T("newFolder"); nb.addEventListener("click", addFolder); nf.appendChild(inp); nf.appendChild(nb); boardsPanel.appendChild(nf); const hint = document.createElement("div"); hint.className = "bp-hint"; hint.textContent = T("renameTip"); boardsPanel.appendChild(hint); } document.addEventListener("langchange", updateBoardsUI);