teach/public/js/widgets/write.js
bes-r d1d3265c6c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
v0.1.02: opsplitsing in css/, js/core|data|board|app en js/widgets/* (geen functionele wijziging)
2026-07-09 21:56:49 +02:00

205 lines
7.8 KiB
JavaScript

/* widget: Schrijfblad */
/* =========================================================
Writing pad — pen on paper with lines or maths squares
==========================================================*/
function mountWrite(root, initState){
const COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#f7c948"];
const SIZES = [3,6,10];
let color = COLORS[0], size = SIZES[1], tool = "pen", // pen | mark | text | erase
bg = (initState && initState.bg) || "write";
root.innerHTML = `
<div class="wp">
<div class="wp-bar">
<div class="wp-group wp-tools"></div>
<div class="wp-group wp-bgs"></div>
<div class="wp-group wp-colors"></div>
<div class="wp-group wp-sizes"></div>
<button class="wp-clear">🗑</button>
</div>
<div class="wp-stage"><canvas></canvas></div>
</div>`;
const stage = root.querySelector(".wp-stage"),
canvas = root.querySelector("canvas"),
ctx = canvas.getContext("2d"),
toolsEl = root.querySelector(".wp-tools"),
bgsEl = root.querySelector(".wp-bgs"),
colorsEl = root.querySelector(".wp-colors"),
sizesEl = root.querySelector(".wp-sizes"),
clearBtn = root.querySelector(".wp-clear");
const TOOLS = [["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
TOOLS.forEach(([id,icon,key])=>{
const b = document.createElement("button");
b.dataset.tool = id; b.textContent = icon;
b.addEventListener("click", ()=>{ tool = id; refresh(); });
toolsEl.appendChild(b);
});
/* background choices */
const BGS = [["blank","bgBlank"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
BGS.forEach(([id,key])=>{
const b = document.createElement("button");
b.className = "wp-bg"; b.dataset.bg = id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ bg = id; refresh(); });
bgsEl.appendChild(b);
});
COLORS.forEach(c=>{
const b = document.createElement("button");
b.className = "wp-color"; b.dataset.c = c;
const i = document.createElement("i"); i.style.background = c;
b.appendChild(i);
b.addEventListener("click", ()=>{ color = c; if(tool==="erase") tool = "pen"; refresh(); });
colorsEl.appendChild(b);
});
SIZES.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", ()=>{ size = s; refresh(); });
sizesEl.appendChild(b);
});
clearBtn.addEventListener("click", ()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); });
function refresh(){
stage.className = "wp-stage" + (bg==="blank" ? "" : " bg-"+bg) + (tool==="text" ? " t-text" : "");
toolsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.tool===tool);
b.title = T(TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
});
bgsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.bg===bg);
b.title = T(BGS.find(x=>x[0]===b.dataset.bg)[1]);
});
colorsEl.querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.c===color && tool!=="erase"));
sizesEl.querySelectorAll("button").forEach(b=>{ b.classList.toggle("on", +b.dataset.s===size); b.title = T("penSize"); });
clearBtn.title = T("clearAll");
}
document.addEventListener("langchange", refresh);
/* canvas sizing (preserves ink on resize) */
const dpr = window.devicePixelRatio || 1;
function resize(){
const w = stage.clientWidth, h = stage.clientHeight;
if(w<2 || h<2) return;
const keep = document.createElement("canvas");
keep.width = canvas.width; keep.height = canvas.height;
if(canvas.width) keep.getContext("2d").drawImage(canvas,0,0);
canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr);
canvas.style.width = w+"px"; canvas.style.height = h+"px";
if(keep.width) ctx.drawImage(keep,0,0);
ctx.lineCap = "round"; ctx.lineJoin = "round";
}
new ResizeObserver(resize).observe(stage);
resize();
/* restore saved ink */
if(initState && initState.ink){
const im = new Image();
im.onload = ()=>{ resize(); ctx.drawImage(im,0,0); };
im.src = initState.ink;
}
/* drawing */
let drawing = false, lx=0, ly=0, nibW = size;
const pos = ev=>{
const r = canvas.getBoundingClientRect();
/* ratio-based: correct under app zoom and widget zoom */
return [(ev.clientX-r.left)*(canvas.width/r.width), (ev.clientY-r.top)*(canvas.height/r.height)];
};
canvas.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
if(tool==="text"){ startText(e); return; }
canvas.setPointerCapture(e.pointerId);
drawing = true;
nibW = size*0.8;
[lx,ly] = pos(e);
strokeTo(lx+0.01, ly+0.01, e.pressure, e.pointerType);
});
canvas.addEventListener("pointermove", e=>{
if(!drawing) return;
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
evs.forEach(ev=>{ const [x,y] = pos(ev); strokeTo(x, y, ev.pressure, ev.pointerType); });
});
const stopDraw = ()=>{ drawing = false; ctx.globalAlpha = 1; };
canvas.addEventListener("pointerup", stopDraw);
canvas.addEventListener("pointercancel", stopDraw);
function strokeTo(x,y,pressure,ptype){
ctx.globalCompositeOperation = tool==="erase" ? "destination-out" : "source-over";
ctx.globalAlpha = tool==="mark" ? 0.32 : 1;
ctx.strokeStyle = color;
let w = size;
if(tool==="erase") w = size*5;
else if(tool==="mark") w = size*3.5;
else if(tool==="vulp"){
/* nib: real pen pressure when available, otherwise slower = thicker */
let target;
if(ptype==="pen" && pressure > 0){
target = size*(0.45 + pressure*1.9);
}else{
const speed = Math.hypot(x-lx, y-ly)/dpr;
target = Math.max(size*0.5, Math.min(size*2.3, size*2.3 - speed*0.12));
}
nibW += (target - nibW)*0.3; /* smooth transitions between thin and thick */
w = nibW;
}
ctx.lineWidth = w*dpr;
ctx.beginPath();
ctx.moveTo(lx,ly);
ctx.lineTo(x,y);
ctx.stroke();
lx = x; ly = y;
}
/* text tool: click the sheet, type, Enter (or click away) to place */
function startText(e){
if(stage.querySelector(".wp-text-input")) return;
const r = stage.getBoundingClientRect();
const sc = r.width / stage.offsetWidth; /* effective zoom on this widget */
const x = (e.clientX - r.left)/sc, y = (e.clientY - r.top)/sc;
const fontPx = size*6 + 10;
const inp = document.createElement("input");
inp.type = "text"; inp.className = "wp-text-input";
inp.style.left = Math.min(x, r.width-140)+"px";
inp.style.top = Math.min(y, r.height-34)+"px";
inp.style.color = color;
inp.style.fontSize = fontPx+"px";
stage.appendChild(inp);
setTimeout(()=>inp.focus(), 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v){
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.font = `700 ${fontPx*dpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
ctx.textBaseline = "top";
ctx.fillText(v, x*dpr, y*dpr);
}
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);
}
refresh();
return {
getState: ()=>{
let ink = null;
try{ ink = canvas.toDataURL("image/png"); }catch(e){}
return { bg, ink };
}
};
}