fix: herstel kleurplaatgereedschappen (v0.4.70-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s

Gewijzigde functionaliteiten:
- Vervang foutgevoelige zwarte en witte SVG-vullingen door transparante contourlagen.
- Laat de verfpot rechtstreeks de aanklikbare kleurvakken bedienen.
- Vervang het kleurpotlood door een vloeiende, pendrukgevoelige kwast met eigen icoon en cursor.
- Migreer opgeslagen potloodstate automatisch naar het nieuwe kwastgereedschap.
- Voeg regressietests toe voor achtergrond, laagweergave en gereedschapsinvoer.

Tests:
- npm test (201 geslaagd)
This commit is contained in:
Ramon 2026-07-29 14:05:57 +02:00
parent 979f30a935
commit 33398c8594
5 changed files with 42 additions and 37 deletions

View file

@ -1 +1 @@
0.4.69-beta
0.4.70-beta

View file

@ -32,22 +32,24 @@
.coloring-svg-lines .coloring-detail{stroke:var(--coloring-detail);stroke-width:3.6;stroke-opacity:.94;pointer-events:none}
.coloring-svg-lines .coloring-detail[data-level="3"]{stroke-width:3;stroke-opacity:.88}
.coloring-svg-lines .coloring-recognition{pointer-events:none}
.coloring-ink-halo,.coloring-ink-overlay{pointer-events:none;shape-rendering:geometricPrecision}.coloring-ink-halo{mix-blend-mode:screen}.coloring-ink-overlay{mix-blend-mode:multiply}
.coloring-ink-halo .coloring-region{fill:#000!important;stroke:#fff;stroke-width:10.25;stroke-opacity:.96}
.coloring-ink-halo,.coloring-ink-overlay{pointer-events:none;shape-rendering:geometricPrecision}
.coloring-ink-halo .coloring-region{fill:none!important;stroke:#fff;stroke-width:10.25;stroke-opacity:.96}
.coloring-ink-halo .coloring-region[data-role="backdrop"]{stroke-width:7.25}
.coloring-ink-halo .coloring-region[data-level="2"]{stroke-width:9.65}
.coloring-ink-halo .coloring-region[data-level="3"]{stroke-width:8.85}
.coloring-ink-halo .coloring-detail{fill:none!important;stroke:#fff!important;stroke-width:7.2;stroke-opacity:.96}
.coloring-ink-halo .coloring-detail[data-level="3"]{stroke-width:6.35}
.coloring-ink-halo .coloring-ink-dot{fill:#fff!important;stroke:#fff!important;stroke-width:4.5}
.coloring-ink-overlay .coloring-region{fill:#fff!important;stroke:var(--coloring-ink);stroke-width:6.5;stroke-opacity:1}
.coloring-ink-overlay .coloring-region{fill:none!important;stroke:var(--coloring-ink);stroke-width:6.5;stroke-opacity:1}
.coloring-ink-overlay .coloring-region[data-role="backdrop"]{stroke-width:4.25}
.coloring-ink-overlay .coloring-region[data-level="2"]{stroke-width:5.9}
.coloring-ink-overlay .coloring-region[data-level="3"]{stroke-width:5.15}
.coloring-ink-overlay .coloring-detail{fill:none!important;stroke:var(--coloring-detail)!important;stroke-width:3.8;stroke-opacity:1}
.coloring-ink-overlay .coloring-detail[data-level="3"]{stroke-width:3.15}
.coloring-ink-overlay .coloring-ink-dot{fill:var(--coloring-ink)!important;stroke:none!important}
.coloring-paper canvas{touch-action:none;cursor:crosshair;mix-blend-mode:multiply}
.coloring-paper canvas{touch-action:none;cursor:crosshair}
.coloring-app[data-tool="brush"] .coloring-paper canvas{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Ctext y='25' font-size='24'%3E%F0%9F%96%8C%EF%B8%8F%3C/text%3E%3C/svg%3E") 5 25,crosshair}
.coloring-app[data-tool="bucket"] .coloring-paper canvas{pointer-events:none}
.coloring-app[data-tool="bucket"] .coloring-art{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Ctext y='24' font-size='23'%3E%F0%9F%AA%A3%3C/text%3E%3C/svg%3E") 6 24,pointer}
.coloring-app[data-tool="bucket"] .coloring-art [data-fillable]{pointer-events:all}
.coloring-app[data-tool="bucket"] .coloring-art [data-fillable]:hover{fill-opacity:.82}

View file

@ -2,7 +2,7 @@
"use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.69-beta";
const VERSION = "0.4.70-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -49,11 +49,11 @@ const I18N = {
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
wg_coloring:"Kleurplaten", wg_coloring_d:"Kies thematische kleurplaten op leerjaar en niveau; kleur met een echt werkend kleurpotlood of vul een vak met de verfemmer.",
wg_coloring:"Kleurplaten", wg_coloring_d:"Kies thematische kleurplaten op leerjaar en niveau; schilder met een kwast of vul een vak met de verfemmer.",
colorTheme:"Thema", colorPage:"Kleurplaat", colorProfile:"Groep {year} · niveau {level}",
colorThemeAll:"Alle thema's", colorThemeAnimals:"Dieren", colorThemeSeasons:"Seizoenen", colorThemeNature:"Natuur", colorThemeScience:"Wetenschap & techniek", colorThemeCulture:"Cultuur & omgeving",
colorToolPencil:"Kleurpotlood", colorToolBucket:"Verfemmer",
colorPencilHint:"Teken meerdere keren over dezelfde plek om de kleur donkerder te maken, net als met een echt kleurpotlood.", colorBucketHint:"Tik op een omsloten vak om het in één keer te kleuren.", colorEraserHint:"Veeg alleen de potloodlaag weg; ingekleurde vakken blijven staan.",
colorToolBrush:"Kwast", colorToolBucket:"Verfemmer",
colorBrushHint:"Schilder vloeiende streken met de kwast; de contourlijnen blijven altijd zichtbaar.", colorBucketHint:"Tik op een omsloten vak om het in één keer te kleuren.", colorEraserHint:"Veeg alleen de kwastlaag weg; ingekleurde vakken blijven staan.",
colorReset:"Kleurplaat leegmaken", colorDone:"Ik ben klaar", colorFinished:"Kleurplaat klaar!",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
@ -479,11 +479,11 @@ const I18N = {
wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
wg_coloring:"Colouring pages", wg_coloring_d:"Choose themed colouring pages by year and level; use a realistic coloured pencil or fill an area with the paint bucket.",
wg_coloring:"Colouring pages", wg_coloring_d:"Choose themed colouring pages by year and level; paint with a brush or fill an area with the paint bucket.",
colorTheme:"Theme", colorPage:"Colouring page", colorProfile:"Year {year} · level {level}",
colorThemeAll:"All themes", colorThemeAnimals:"Animals", colorThemeSeasons:"Seasons", colorThemeNature:"Nature", colorThemeScience:"Science & technology", colorThemeCulture:"Culture & surroundings",
colorToolPencil:"Coloured pencil", colorToolBucket:"Paint bucket",
colorPencilHint:"Draw over the same spot to make the colour darker, just like a real coloured pencil.", colorBucketHint:"Tap an enclosed area to colour it in one go.", colorEraserHint:"Erase only the pencil layer; filled areas stay in place.",
colorToolBrush:"Brush", colorToolBucket:"Paint bucket",
colorBrushHint:"Paint smooth strokes with the brush; the outlines always stay visible.", colorBucketHint:"Tap an enclosed area to colour it in one go.", colorEraserHint:"Erase only the brush layer; filled areas stay in place.",
colorReset:"Clear colouring page", colorDone:"I'm finished", colorFinished:"Colouring page finished!",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares",

View file

@ -1,4 +1,4 @@
/* teach - thematische kleurplaten met gekleurd potlood en vlakvulling */
/* teach - thematische kleurplaten met kwast en vlakvulling */
"use strict";
const COLORING_THEME_KEYS=Object.freeze({
@ -346,7 +346,7 @@ function coloringPagesFor(yearGroup,theme="all"){
const year=Math.max(1,Math.min(8,Number(yearGroup)||4));
return COLORING_PAGES.filter(page=>year>=page.years[0]&&year<=page.years[1]&&(theme==="all"||page.theme===theme));
}
function coloringPencilWidth(size,pressure,pointerType){
function coloringBrushWidth(size,pressure,pointerType){
const base=Math.max(2,Math.min(18,Number(size)||6));
return pointerType==="pen"&&pressure>0?base*(.45+pressure*1.2):base;
}
@ -365,7 +365,8 @@ function coloringInkOverlay(svg,className){
function mountColoring(root,initState,opts={}){
const yearGroup=Math.max(1,Math.min(8,Number(initState&&initState.yearGroup)||4));
const level=Math.max(1,Math.min(3,Number(initState&&initState.level)||2));
let tool=["pencil","bucket","eraser"].includes(initState&&initState.tool)?initState.tool:"pencil";
const savedTool=initState&&initState.tool==="pencil"?"brush":initState&&initState.tool;
let tool=["brush","bucket","eraser"].includes(savedTool)?savedTool:"brush";
let color=coloringSafeColor(initState&&initState.color);
let size=[4,7,12].includes(Number(initState&&initState.size))?Number(initState.size):7;
let theme=COLORING_THEME_KEYS[initState&&initState.theme]?initState.theme:"all";
@ -394,12 +395,12 @@ function mountColoring(root,initState,opts={}){
const art=root.querySelector(".coloring-art"),inkHost=root.querySelector(".coloring-ink"),paper=root.querySelector(".coloring-paper"),canvas=root.querySelector("canvas"),ctx=canvas.getContext("2d");
const toolHost=root.querySelector(".coloring-tool-buttons"),palette=root.querySelector(".coloring-palette"),sizes=root.querySelector(".coloring-sizes");
const custom=root.querySelector(".coloring-custom input"),reset=root.querySelector(".coloring-reset"),done=root.querySelector(".coloring-done"),hint=root.querySelector(".coloring-hint");
const TOOLS=[["pencil","✏️","colorToolPencil"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
const TOOLS=[["brush","🖌️","colorToolBrush"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
TOOLS.forEach(([id,icon])=>{const button=document.createElement("button");button.type="button";button.dataset.tool=id;button.textContent=icon;button.onclick=()=>{tool=id;refreshTools()};toolHost.appendChild(button)});
COLORING_COLORS.forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="pencil";refreshTools()};palette.appendChild(button)});
COLORING_COLORS.forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="brush";refreshTools()};palette.appendChild(button)});
[4,7,12].forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-size";button.dataset.size=value;const dot=document.createElement("i");dot.style.width=dot.style.height=(value+5)+"px";button.appendChild(dot);button.onclick=()=>{size=value;refreshTools()};sizes.appendChild(button)});
custom.value=color;custom.oninput=()=>{color=coloringSafeColor(custom.value,color);if(tool==="eraser")tool="pencil";refreshTools()};
custom.value=color;custom.oninput=()=>{color=coloringSafeColor(custom.value,color);if(tool==="eraser")tool="brush";refreshTools()};
function pageTitle(page){return page.title[LANG]||page.title.nl}
function eligibleThemes(){return [...new Set(coloringPagesFor(yearGroup).map(page=>page.theme))]}
@ -441,7 +442,7 @@ function mountColoring(root,initState,opts={}){
custom.parentElement.querySelector("span").textContent=T("customColor");
reset.title=T("colorReset");reset.setAttribute("aria-label",T("colorReset"));
done.textContent=completed?"✓ "+T("colorFinished"):T("colorDone");
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint");
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorBrushHint");
const selected=pageId;rebuildPageOptions(selected);
}
function refreshTools(){
@ -449,7 +450,7 @@ function mountColoring(root,initState,opts={}){
palette.querySelectorAll("button").forEach(button=>button.classList.toggle("on",button.dataset.color===color&&tool!=="eraser"));
sizes.querySelectorAll("button").forEach(button=>{button.classList.toggle("on",Number(button.dataset.size)===size);button.title=T("penSize")});
app.dataset.tool=tool;canvas.style.pointerEvents=tool==="bucket"?"none":"auto";art.style.pointerEvents=tool==="bucket"?"auto":"none";
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint");
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorBrushHint");
}
themeSel.onchange=()=>{theme=themeSel.value;rebuildPageOptions("");renderPage(false)};
pageSel.onchange=()=>{pageId=pageSel.value;renderPage(false)};
@ -460,15 +461,14 @@ function mountColoring(root,initState,opts={}){
const point=event=>{const rect=canvas.getBoundingClientRect();return{x:(event.clientX-rect.left)*canvas.width/rect.width,y:(event.clientY-rect.top)*canvas.height/rect.height}};
let drawing=false,last=null,mid=null;
function pencilSegment(next,event){
const width=coloringPencilWidth(size,event.pressure,event.pointerType),erase=tool==="eraser";
ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.27;ctx.lineCap="round";ctx.lineJoin="round";ctx.lineWidth=erase?width*3.5:width;
function brushSegment(next,event){
const width=coloringBrushWidth(size,event.pressure,event.pointerType),erase=tool==="eraser";
ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.72;ctx.lineCap="round";ctx.lineJoin="round";ctx.lineWidth=erase?width*3.5:width;
const nextMid={x:(last.x+next.x)/2,y:(last.y+next.y)/2};ctx.beginPath();ctx.moveTo(mid?mid.x:last.x,mid?mid.y:last.y);ctx.quadraticCurveTo(last.x,last.y,nextMid.x,nextMid.y);ctx.stroke();
if(!erase){for(let n=0;n<2;n++){const jitter=(Math.random()-.5)*width*.65;ctx.globalAlpha=.07;ctx.lineWidth=Math.max(1,width*.28);ctx.beginPath();ctx.moveTo(last.x+jitter,last.y-jitter);ctx.lineTo(next.x-jitter,next.y+jitter);ctx.stroke()}}
ctx.restore();last=next;mid=nextMid;
}
canvas.addEventListener("pointerdown",event=>{if(tool==="bucket"||(event.pointerType==="mouse"&&event.button!==0))return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;last=point(event);mid=null;pencilSegment({x:last.x+.01,y:last.y+.01},event);completed=false;updateTexts()});
canvas.addEventListener("pointermove",event=>{if(!drawing)return;(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>pencilSegment(point(sample),sample))});
canvas.addEventListener("pointerdown",event=>{if(tool==="bucket"||(event.pointerType==="mouse"&&event.button!==0))return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;last=point(event);mid=null;brushSegment({x:last.x+.01,y:last.y+.01},event);completed=false;updateTexts()});
canvas.addEventListener("pointermove",event=>{if(!drawing)return;(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>brushSegment(point(sample),sample))});
const stop=()=>{drawing=false;last=mid=null};canvas.addEventListener("pointerup",stop);canvas.addEventListener("pointercancel",stop);
reset.onclick=()=>{fills={};completed=false;clearInk();renderPage(false)};
done.onclick=()=>{completed=true;updateTexts();if(typeof opts.onProgress==="function")opts.onProgress({attempts:1,correct:1,stars:1,detail:{question:pageTitle(COLORING_PAGES.find(page=>page.id===pageId)),answer:T("colorFinished"),correct:true}})};

View file

@ -13,7 +13,7 @@ async function coloringContext(){
pages:COLORING_PAGES,
themes:COLORING_THEME_KEYS,
pagesFor:coloringPagesFor,
pencilWidth:coloringPencilWidth,
brushWidth:coloringBrushWidth,
safeColor:coloringSafeColor
}`,context);
return{source,api:context.__coloring};
@ -34,7 +34,7 @@ test('kleurplatenwidget laadt vóór het bord en is als creatieve leerlingwidget
assert.match(pupil,/PUPIL_WIDGET_CATS\s*=\s*\[[^\]]*"creatief"/);
assert.match(cast,/CAST_WIDGET_CATEGORIES=new Set\(\[[^\]]*"creatief"/);
assert.match(css,/\.coloring-paper[^}]*aspect-ratio:4\/3/);
for(const key of ['wg_coloring','wg_coloring_d','catCreative','colorToolPencil','colorToolBucket','colorBucketHint','colorPencilHint']){
for(const key of ['wg_coloring','wg_coloring_d','catCreative','colorToolBrush','colorToolBucket','colorBucketHint','colorBrushHint']){
assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
}
});
@ -96,15 +96,17 @@ test('elk leerjaar heeft zeven herkenbare kleurplaten met diverse themas en d
assert.equal(api.pagesFor(3,'science').length,1);
});
test('potlood reageert op echte pendruk en de bovenste inktlaag houdt lijnen zichtbaar',async()=>{
test('kwast reageert op pendruk en transparante inktlagen houden lijnen zichtbaar',async()=>{
const {source,api}=await coloringContext();
assert.ok(api.pencilWidth(7,.9,'pen')>api.pencilWidth(7,.2,'pen'));
assert.equal(api.pencilWidth(7,0,'mouse'),7);
assert.ok(api.brushWidth(7,.9,'pen')>api.brushWidth(7,.2,'pen'));
assert.equal(api.brushWidth(7,0,'mouse'),7);
assert.equal(api.safeColor('#AABBCC'),'#aabbcc');
assert.equal(api.safeColor('red'),'#e0554d');
for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.27','quadraticCurveTo','Math.random()'])assert.ok(source.includes(feature),feature);
for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.72','quadraticCurveTo'])assert.ok(source.includes(feature),feature);
assert.match(source,/const savedTool=initState&&initState\.tool==="pencil"\?"brush"/);
assert.match(source,/\["brush","🖌️","colorToolBrush"\]/);
const css=await readFile('public/css/coloring.css','utf8');
assert.match(css,/mix-blend-mode:multiply/);
assert.doesNotMatch(css,/mix-blend-mode/);
assert.match(source,/class="coloring-art"><\/div><canvas[^>]+><\/canvas><div class="coloring-ink" aria-hidden="true"><\/div>/);
assert.match(source,/coloringInkOverlay\(svg,"coloring-ink-halo"\)/);
assert.match(source,/coloringInkOverlay\(svg,"coloring-ink-overlay"\)/);
@ -117,14 +119,15 @@ test('potlood reageert op echte pendruk en de bovenste inktlaag houdt lijnen zic
assert.match(css,/\.coloring-svg-lines\{shape-rendering:geometricPrecision\}/);
assert.match(css,/\.coloring-region\[data-role="backdrop"\]\{stroke-width:4\.25\}/);
assert.match(css,/\.coloring-art\{z-index:1\}\.coloring-paper canvas\{z-index:2\}\.coloring-ink\{z-index:3;pointer-events:none\}/);
assert.match(css,/\.coloring-ink-halo\{mix-blend-mode:screen\}\.coloring-ink-overlay\{mix-blend-mode:multiply\}/);
assert.match(css,/\.coloring-ink-halo \.coloring-region\{fill:#000!important;stroke:#fff;stroke-width:10\.25;stroke-opacity:\.96\}/);
assert.match(css,/\.coloring-ink-overlay \.coloring-region\{fill:#fff!important;stroke:var\(--coloring-ink\);stroke-width:6\.5;stroke-opacity:1\}/);
assert.match(css,/\.coloring-ink-halo \.coloring-region\{fill:none!important;stroke:#fff;stroke-width:10\.25;stroke-opacity:\.96\}/);
assert.match(css,/\.coloring-ink-overlay \.coloring-region\{fill:none!important;stroke:var\(--coloring-ink\);stroke-width:6\.5;stroke-opacity:1\}/);
assert.match(css,/\.coloring-ink-overlay \.coloring-detail\{fill:none!important;stroke:var\(--coloring-detail\)!important;stroke-width:3\.8;stroke-opacity:1\}/);
assert.match(css,/\.coloring-ink-overlay \.coloring-ink-dot\{fill:var\(--coloring-ink\)!important;stroke:none!important\}/);
assert.match(css,/\.coloring-app\[data-tool="bucket"\] \.coloring-paper canvas\{pointer-events:none\}/);
assert.match(source,/canvas\.style\.pointerEvents=tool==="bucket"\?"none":"auto";art\.style\.pointerEvents=tool==="bucket"\?"auto":"none"/);
});
test('verfemmer vult een SVG-vak in één keer en state bewaart potlood én vlakkleuren',async()=>{
test('verfemmer vult een SVG-vak in één keer en state bewaart kwast én vlakkleuren',async()=>{
const {source}=await coloringContext();
assert.match(source,/event\.target\.closest\("\[data-fillable\]\[data-part\]\"\)/);
assert.match(source,/part\.setAttribute\("fill",color\)/);