Files
crumbmissions/crumbblocks/bezier_stream.html
2025-12-23 20:52:00 +01:00

453 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Schnippsi Painter — Stream + Bezier</title>
<style>
:root{
--bg:#0b0f12; --panel:#12181f; --ink:#e2f2ff; --muted:#7a8aa0; --accent:#62d3a4; --accent2:#ffd166;
}
html,body{height:100%;margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial}
#wrap{display:grid;grid-template-rows:auto 1fr; height:100%;}
#toolbar{
display:flex;gap:.75rem;align-items:center; padding:.6rem .8rem; background:linear-gradient(180deg,#12181f,#0e141a);
position:sticky; top:0; z-index:3; box-shadow:0 8px 20px rgba(0,0,0,.25);
}
#toolbar input[type=color], #toolbar input[type=text]{height:32px}
#toolbar .btn{
padding:.55rem .8rem; border-radius:14px; background:#1a2330; color:#cfe7ff; border:1px solid #2b3a4b; cursor:pointer;
}
#toolbar .btn[aria-pressed="true"]{background:linear-gradient(180deg,#1f2a36,#1a2330); outline:2px solid var(--accent);}
#toolbar .danger{border-color:#603; color:#ffd9e6; background:#2a0f1f}
#toolbar .ok{border-color:#1b5e3b; background:#0f241a; color:#d9ffef;}
#toolbar .field{display:flex;align-items:center;gap:.4rem}
#status{margin-left:auto; font-size:.9rem; color:var(--muted)}
#canvasWrap{position:relative; height:100%;}
canvas{position:absolute; inset:0; width:100%; height:100%; touch-action:none; background:#0a0d11; outline:none}
#help{
position:fixed; right:.8rem; bottom:.8rem; width:360px; max-width:95vw; background:#0e141a; border:1px solid #213041;
border-radius:16px; padding:12px 14px; color:#cfe7ff; box-shadow:0 18px 40px rgba(0,0,0,.45); font-size:14px;
}
#help h3{margin:.2rem 0 .4rem; font-size:16px}
#help kbd{background:#111a22; padding:.15rem .35rem; border-radius:6px; border:1px solid #2b3a4b}
.pill{padding:.08rem .45rem; background:#13202a; border:1px solid #254257; border-radius:999px; font-size:12px; color:#bfe8ff}
.handle{cursor:grab}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
</style>
</head>
<body>
<div id="wrap">
<div id="toolbar" role="toolbar" aria-label="Zeichenwerkzeuge">
<div class="field"><span class="pill">Tool</span>
<button class="btn" id="toolFree" aria-pressed="true" aria-label="Freihand (F)">Freihand</button>
<button class="btn" id="toolBezier" aria-pressed="false" aria-label="Bezier (B)">Bezier</button>
</div>
<div class="field"><span class="pill">Farbe A</span><input id="colA" type="color" value="#62d3a4"/></div>
<div class="field"><span class="pill">Farbe B</span><input id="colB" type="color" value="#ffd166"/></div>
<div class="field"><span class="pill">Breite</span><input id="width" type="range" min="1" max="48" value="12"/></div>
<div class="field"><span class="pill">Stream</span>
<input id="wsUrl" type="text" size="22" value="ws://localhost:9980" aria-label="WebSocket URL"/>
<button class="btn" id="wsToggle" aria-pressed="false" aria-label="Streaming an/aus (S)">Verbinden</button>
</div>
<div class="field">
<button class="btn" id="replayBtn" aria-label="Replay (R)">Replay</button>
<button class="btn" id="exportJson">JSON</button>
<button class="btn" id="exportSvg">SVG</button>
<button class="btn" id="exportPng">PNG</button>
<button class="btn danger" id="clearBtn">Clear</button>
</div>
<div id="status" aria-live="polite">bereit</div>
</div>
<div id="canvasWrap">
<canvas id="cv" role="application" aria-label="Schnippsi Zeichenfläche" tabindex="0"></canvas>
</div>
</div>
<aside id="help" aria-live="polite">
<h3>Shortcuts</h3>
<div>Hilfe <kbd>?</kbd> / <kbd>H</kbd> • Tool-Bezier <kbd>B</kbd> • Tool-Freihand <kbd>F</kbd> • Stream <kbd>S</kbd></div>
<div>Bezier beenden <kbd>Enter</kbd> • Letzten Anker löschen <kbd>Backspace</kbd> • Ecke halten <kbd>Alt</kbd> • Snap <kbd>Shift</kbd></div>
<div class="pill">A11y</div> Tab-fokussierbar, ARIA Live-Status, hoher Kontrast, 200% Zoom stabil.
</aside>
<script>
(()=>{
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const statusEl = document.getElementById('status');
const toolFree = document.getElementById('toolFree');
const toolBezier = document.getElementById('toolBezier');
const colA = document.getElementById('colA');
const colB = document.getElementById('colB');
const widthIn = document.getElementById('width');
const replayBtn = document.getElementById('replayBtn');
const exportJson = document.getElementById('exportJson');
const exportSvg = document.getElementById('exportSvg');
const exportPng = document.getElementById('exportPng');
const clearBtn = document.getElementById('clearBtn');
const wsToggle = document.getElementById('wsToggle');
const wsUrl = document.getElementById('wsUrl');
let state = {
tool: 'free', // 'free' | 'bezier'
colorA: colA.value,
colorB: colB.value,
width: +widthIn.value,
strokes: [],
current: null,
sessionId: ('sess_' + Math.random().toString(16).slice(2)),
streaming: false,
ws: null,
down: false,
modifiers: {shift:false, alt:false},
bezier: {anchors:[], dragging:null}
};
function resize(){
const rect = cv.getBoundingClientRect();
cv.width = Math.floor(rect.width * dpr);
cv.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
render();
}
new ResizeObserver(resize).observe(document.getElementById('canvasWrap'));
resize();
function setStatus(t){ statusEl.textContent = t; }
// WebSocket
function wsConnect(){
try{
const url = wsUrl.value.trim();
state.ws = new WebSocket(url);
state.ws.onopen = ()=>{ state.streaming = true; wsToggle.setAttribute('aria-pressed','true'); setStatus('WS verbunden'); send({type:'session', id:state.sessionId, app:'schnippsi-painter', t:performance.now()|0}); };
state.ws.onclose = ()=>{ state.streaming = false; wsToggle.setAttribute('aria-pressed','false'); setStatus('WS getrennt'); };
state.ws.onerror = ()=>{ setStatus('WS Fehler'); };
wsToggle.textContent = 'Trennen';
}catch(e){ console.error(e); setStatus('WS Verbindungsfehler'); }
}
function wsDisconnect(){
if(state.ws){ try{ state.ws.close(); }catch(e){} }
state.ws = null; state.streaming = false; wsToggle.textContent = 'Verbinden';
}
function send(obj){
if(!state.streaming || !state.ws || state.ws.readyState!==1) return;
try{ state.ws.send(JSON.stringify(obj)); }catch(e){ /* ignore */ }
}
// Tools
function useTool(t){
state.tool = t;
toolFree.setAttribute('aria-pressed', t==='free');
toolBezier.setAttribute('aria-pressed', t==='bezier');
setStatus(t==='free'?'Freihand aktiv':'Bezier aktiv');
}
toolFree.onclick = ()=>useTool('free');
toolBezier.onclick = ()=>useTool('bezier');
colA.oninput = ()=>{ state.colorA = colA.value; };
colB.oninput = ()=>{ state.colorB = colB.value; };
widthIn.oninput = ()=>{ state.width = +widthIn.value; };
wsToggle.onclick = ()=>{
if(state.streaming) wsDisconnect();
else wsConnect();
};
clearBtn.onclick = ()=>{ state.strokes.length=0; state.bezier.anchors=[]; state.current=null; render(); };
// Geometry helpers
const dist = (a,b)=>Math.hypot(a.x-b.x, a.y-b.y);
function lerp(a,b,t){ return a+(b-a)*t; }
function cubicPoint(p0,p1,p2,p3,t){
const x = Math.pow(1-t,3)*p0.x + 3*Math.pow(1-t,2)*t*p1.x + 3*(1-t)*t*t*p2.x + t*t*t*p3.x;
const y = Math.pow(1-t,3)*p0.y + 3*Math.pow(1-t,2)*t*p1.y + 3*(1-t)*t*t*p2.y + t*t*t*p3.y;
return {x,y};
}
// Drawing primitives
function drawStroke(s){
ctx.lineJoin = ctx.lineCap = 'round';
ctx.lineWidth = s.width;
ctx.strokeStyle = s.colorA;
ctx.globalAlpha = 1;
ctx.beginPath();
if(s.isBezier){
const A = s.anchors;
if(A.length>0){ ctx.moveTo(A[0].x, A[0].y); }
for(let i=1;i<A.length;i++){
const a0=A[i-1], a1=A[i];
ctx.bezierCurveTo(a0.h2.x, a0.h2.y, a1.h1.x, a1.h1.y, a1.x, a1.y);
}
ctx.stroke();
// Handles (edit mode)
if(s._edit){
for(const a of A){
// anchor
ctx.fillStyle = '#cfe7ff';
ctx.beginPath(); ctx.arc(a.x,a.y,4,0,Math.PI*2); ctx.fill();
// handles
ctx.strokeStyle='#2b9ad1'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(a.h1.x,a.h1.y); ctx.moveTo(a.x,a.y); ctx.lineTo(a.h2.x,a.h2.y); ctx.stroke();
ctx.fillStyle='#2b9ad1';
ctx.beginPath(); ctx.arc(a.h1.x,a.h1.y,3,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(a.h2.x,a.h2.y,3,0,Math.PI*2); ctx.fill();
}
}
}else{
const P = s.points;
if(!P || P.length<2) return;
ctx.moveTo(P[0].x, P[0].y);
for(let i=1;i<P.length;i++){ ctx.lineTo(P[i].x, P[i].y); }
ctx.stroke();
}
}
function render(){
ctx.clearRect(0,0,cv.width,dpr?cv.height:cv.height);
// backdrop grid (subtle)
ctx.save();
ctx.strokeStyle='rgba(255,255,255,0.04)';
ctx.lineWidth=1;
for(let x=0;x<cv.width/dpr;x+=40){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,cv.height/dpr); ctx.stroke(); }
for(let y=0;y<cv.height/dpr;y+=40){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(cv.width/dpr,y); ctx.stroke(); }
ctx.restore();
for(const s of state.strokes){ drawStroke(s); }
if(state.current){ drawStroke(state.current); }
}
// Input
let lastEmit = 0;
function pointerPos(e){
const r = cv.getBoundingClientRect();
return { x:(e.clientX - r.left), y:(e.clientY - r.top), t: performance.now()|0, p:e.pressure||0.5, tilt:[e.tiltX||0, e.tiltY||0], type:e.pointerType||'mouse' };
}
function startStroke(pt){
if(state.tool==='free'){
state.current = { id:('st_'+Date.now()), tool:'free', colorA:state.colorA, colorB:state.colorB, width:state.width, points:[pt], isBezier:false };
send({type:'strokeStart', id:state.current.id, tool:'free', colorA:state.colorA, colorB:state.colorB, width:state.width, t:pt.t});
}else{
// initialize bezier edit stroke
const A = state.bezier.anchors.length? structuredClone(state.bezier.anchors) : [];
state.current = { id:('st_'+Date.now()), tool:'bezier', colorA:state.colorA, colorB:state.colorB, width:state.width, anchors:A, isBezier:true, _edit:true };
if(A.length===0){
// first anchor with mirrored handles
const a = {x:pt.x,y:pt.y, h1:{x:pt.x-40, y:pt.y}, h2:{x:pt.x+40, y:pt.y}, split:false, t:pt.t, p:pt.p, tilt:pt.tilt};
state.bezier.anchors.push(a);
state.current.anchors.push(structuredClone(a));
send({type:'strokeStart', id:state.current.id, tool:'bezier', colorA:state.colorA, colorB:state.colorB, width:state.width, t:pt.t});
send({type:'anchor', id:state.current.id, a:a});
}
}
}
function moveStroke(pt){
if(!state.current) return;
if(state.tool==='free'){
state.current.points.push(pt);
const now = performance.now();
if(now-lastEmit>33){ lastEmit=now; send({type:'point', id:state.current.id, p:pt}); }
}else{
// handle dragging logic
const cur = state.current;
if(!state.bezier.dragging) return;
const drag = state.bezier.dragging;
if(drag.kind==='anchor'){
drag.ref.x = pt.x; drag.ref.y = pt.y;
// move handles with anchor
drag.ref.h1.x += drag.dx? (pt.x-drag.base.x) : 0;
drag.ref.h1.y += drag.dy? (pt.y-drag.base.y) : 0;
drag.ref.h2.x += drag.dx? (pt.x-drag.base.x) : 0;
drag.ref.h2.y += drag.dy? (pt.y-drag.base.y) : 0;
drag.base.x = pt.x; drag.base.y = pt.y;
}else if(drag.kind==='h1' || drag.kind==='h2'){
drag.ref[drag.kind].x = pt.x; drag.ref[drag.kind].y = pt.y;
if(!drag.ref.split){
// mirror handle lengths for smooth
const ax=drag.ref.x, ay=drag.ref.y;
const opp = (drag.kind==='h1')?'h2':'h1';
const vx = ax - pt.x, vy = ay - pt.y;
drag.ref[opp].x = ax + vx; drag.ref[opp].y = ay + vy;
}
}
cur.anchors = structuredClone(state.bezier.anchors);
// emit throttle
const now = performance.now();
if(now-lastEmit>50){ lastEmit=now; send({type:'anchors', id:cur.id, anchors:cur.anchors}); }
}
render();
}
function endStroke(pt){
if(!state.current) return;
if(state.tool==='free'){
state.strokes.push(state.current);
send({type:'strokeEnd', id:state.current.id, t:pt.t});
state.current=null;
}else{
// in bezier tool, mouseup only stops dragging
state.bezier.dragging=null;
}
render();
}
// Hit-testing for bezier edit
function hitTestAnchors(x,y){
const A = state.bezier.anchors;
for(let i=A.length-1;i>=0;i--){
const a=A[i];
if(dist({x,y}, a)<8) return {idx:i, kind:'anchor', ref:a};
if(dist({x,y}, a.h1)<7) return {idx:i, kind:'h1', ref:a};
if(dist({x,y}, a.h2)<7) return {idx:i, kind:'h2', ref:a};
}
return null;
}
// Add anchor with dragging of handle based on movement
function addAnchor(pt){
const A = state.bezier.anchors;
const prev = A[A.length-1];
const a = {x:pt.x,y:pt.y, h1:{x:pt.x-40, y:pt.y}, h2:{x:pt.x+40, y:pt.y}, split:false, t:pt.t, p:pt.p, tilt:pt.tilt};
if(prev){
// make smooth by default: h1 points to previous, h2 mirrored
a.h1 = {x: lerp(prev.x, pt.x, .33), y: lerp(prev.y, pt.y, .33)};
a.h2 = {x: lerp(pt.x, prev.x, .33), y: lerp(pt.y, prev.y, .33)};
}
A.push(a);
if(state.current){ state.current.anchors = structuredClone(A); }
send({type:'anchor', id:state.current?state.current.id:('st_'+Date.now()), a:a});
render();
}
// Pointer events
cv.addEventListener('pointerdown', (e)=>{
cv.setPointerCapture(e.pointerId);
const pt = pointerPos(e); state.down=true;
if(state.tool==='free'){
startStroke(pt);
}else{
// bezier: check hit on existing handle/anchor first
const hit = hitTestAnchors(pt.x,pt.y);
if(hit){
state.bezier.dragging={...hit, base:{x:pt.x,y:pt.y}, dx:true, dy:true};
if(hit.kind!=='anchor' && e.altKey){ hit.ref.split = true; } // allow cusp with Alt while grabbing
}else{
if(!state.current){ startStroke(pt); }
addAnchor(pt);
}
}
render();
});
cv.addEventListener('pointermove', (e)=>{
if(!state.down && state.tool==='bezier'){
// hover cursor
const pt = pointerPos(e), hit = hitTestAnchors(pt.x,pt.y);
cv.style.cursor = hit ? 'grab' : 'crosshair';
}
if(!state.down) return;
moveStroke(pointerPos(e));
});
cv.addEventListener('pointerup', (e)=>{ state.down=false; endStroke(pointerPos(e)); });
cv.addEventListener('pointercancel', (e)=>{ state.down=false; endStroke(pointerPos(e)); });
// Keyboard
window.addEventListener('keydown', (e)=>{
if(e.key==='Shift') state.modifiers.shift=true;
if(e.key==='Alt') state.modifiers.alt=true;
if(e.key==='b' || e.key==='B'){ useTool('bezier'); }
if(e.key==='f' || e.key==='F'){ useTool('free'); }
if(e.key==='s' || e.key==='S'){ state.streaming?wsDisconnect():wsConnect(); }
if(e.key==='r' || e.key==='R'){ doReplay(); }
if(e.key==='Enter' && state.tool==='bezier'){
// finalize bezier stroke into strokes
if(state.current){
const finalized = {...state.current};
finalized._edit=false;
state.strokes.push(finalized);
send({type:'strokeEnd', id:finalized.id, t:performance.now()|0});
state.current=null;
state.bezier.anchors = [];
render();
}
}
if(e.key==='Backspace' && state.tool==='bezier'){
if(state.bezier.anchors.length>0){
const a = state.bezier.anchors.pop();
send({type:'anchorDel', id:state.current?state.current.id:null, aidx: state.bezier.anchors.length});
if(state.current) state.current.anchors = structuredClone(state.bezier.anchors);
render();
}
e.preventDefault();
}
if(e.key==='?' || e.key==='h' || e.key==='H'){
const el=document.getElementById('help'); el.hidden = !el.hidden;
}
});
window.addEventListener('keyup', (e)=>{
if(e.key==='Shift') state.modifiers.shift=false;
if(e.key==='Alt') state.modifiers.alt=false;
});
// Export
function download(name, dataUrl){
const a=document.createElement('a'); a.href=dataUrl; a.download=name; a.click();
}
exportJson.onclick = ()=>{
const payload = {
canvas:{w:cv.width/dpr, h:cv.height/dpr, dpr},
session:{id:state.sessionId, app:'schnippsi-painter'},
strokes: state.strokes
};
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
download(`schnippsi_${Date.now()}.json`, URL.createObjectURL(blob));
};
exportSvg.onclick = ()=>{
const w=cv.width/dpr, h=cv.height/dpr;
function pathFromStroke(s){
if(s.isBezier){
const A=s.anchors; if(A.length<1) return '';
let d=`M ${A[0].x} ${A[0].y}`;
for(let i=1;i<A.length;i++){
const a0=A[i-1], a1=A[i];
d+=` C ${a0.h2.x} ${a0.h2.y}, ${a1.h1.x} ${a1.h1.y}, ${a1.x} ${a1.y}`;
}
return `<path d="${d}" stroke="${s.colorA}" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="${s.width}"/>`;
}else{
const P=s.points; if(!P || P.length<2) return '';
const d='M '+P.map((p,i)=> (i? 'L '+p.x+' '+p.y : p.x+' '+p.y)).join(' ');
return `<path d="${d}" stroke="${s.colorA}" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="${s.width}"/>`;
}
}
const body = state.strokes.map(pathFromStroke).join('\n');
const svg = `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n${body}\n</svg>`;
const blob = new Blob([svg], {type:'image/svg+xml'});
download(`schnippsi_${Date.now()}.svg`, URL.createObjectURL(blob));
};
exportPng.onclick = ()=>{
const url = cv.toDataURL('image/png');
download(`schnippsi_${Date.now()}.png`, url);
};
// Replay
function doReplay(){
if(state.strokes.length===0) return;
const copy = JSON.parse(JSON.stringify(state.strokes));
state.strokes = [];
let idx=0;
function step(){
if(idx>=copy.length) return;
state.strokes.push(copy[idx++]); render();
setTimeout(step, 300);
}
step();
}
replayBtn.onclick = doReplay;
// Initial tip
setStatus('Bezier: Klicken für Anker, ziehen für Griffe. Enter = fertig. Alt zum Brechen, Shift für Snap.');
})();
</script>
</body>
</html>