648 lines
28 KiB
HTML
648 lines
28 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>One-Liner Painter + Bezier-Editor (Fix)</title>
|
||
<style>
|
||
:root{ --bg:#0b0b10; --panel:#12121a; --muted:#9aa3b2; --accent:#62d3a4; --accent2:#ffd166; --danger:#ff5c5c; }
|
||
*{box-sizing:border-box}
|
||
html,body{height:100%}
|
||
body{margin:0;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg);color:#e7eaf0;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial}
|
||
header,footer{padding:12px 16px;background:var(--panel);border-bottom:1px solid #1e2230}
|
||
footer{border-top:1px solid #1e2230;border-bottom:none;font-size:.9rem;color:var(--muted)}
|
||
h1{font-size:1.05rem;margin:0}
|
||
.wrap{display:grid;grid-template-columns:370px 1fr;gap:16px;padding:16px}
|
||
@media (max-width:1000px){.wrap{grid-template-columns:1fr}}
|
||
.panel{background:var(--panel);border:1px solid #1e2230;border-radius:12px;padding:14px;display:grid;gap:12px}
|
||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||
.col{display:grid;gap:10px}
|
||
label{font-size:.9rem;color:#cfd5e3}
|
||
input[type="text"],textarea,select{width:100%;padding:10px;border-radius:10px;border:1px solid #2a3042;background:#0f121a;color:#e7eaf0}
|
||
input[type="color"]{width:48px;height:36px;border:none;background:none}
|
||
.btn{appearance:none;border:none;border-radius:10px;padding:10px 12px;background:#1f2636;color:#e7eaf0;cursor:pointer}
|
||
.btn:hover{background:#28314a}
|
||
.btn.accent{background:var(--accent);color:#0b1114;font-weight:600}
|
||
.btn.warn{background:var(--danger);color:#0b0b10}
|
||
.chip{display:inline-flex;align-items:center;gap:8px;background:#0f121a;border:1px dashed #2a3042;border-radius:10px;padding:6px 10px}
|
||
.kbd{padding:1px 6px;border-radius:8px;background:#1e2333;color:#cfd5e3;font-family:ui-monospace,Menlo,Monaco,monospace}
|
||
ul#strokeList{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-height:240px;overflow:auto}
|
||
li.strokeItem{display:flex;align-items:center;gap:8px;background:#0f121a;border:1px solid #2a3042;border-radius:10px;padding:8px}
|
||
.canvasWrap{position:relative;background:#0f121a;border:1px solid #1e2230;border-radius:12px;overflow:hidden}
|
||
svg{display:block;width:100%;height:100%;background:#0f121a}
|
||
.badge{font-size:.75rem;color:#0b1114;background:var(--accent);border-radius:999px;padding:2px 8px}
|
||
.muted{color:var(--muted)}
|
||
/* Bezier UI visuals */
|
||
.sline{fill:none;stroke-linecap:round;stroke-linejoin:round}
|
||
.markerStart{fill:#35c759;stroke:none}
|
||
.markerStop{fill:#ff3b30;stroke:none}
|
||
.anchor{fill:#31e07b;stroke:#0b0b10;stroke-width:2;cursor:grab; pointer-events:all}
|
||
.handle{fill:#ffe08a;stroke:#0b0b10;stroke-width:2;cursor:grab; pointer-events:all}
|
||
.handleLine{stroke:#ffe08a;stroke-width:2.5;stroke-dasharray:4 4; pointer-events:none}
|
||
.selected{filter:drop-shadow(0 0 4px #ffd166)}
|
||
#bezierUI{ pointer-events:none } /* UI-Schicht blockiert keine Klicks auf Pfad */
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>One-Liner Painter → SVG/PNG/JSON • Replay • <span class="badge">Bezier-Editor (Fix)</span></h1>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<aside class="panel" aria-labelledby="controlsTitle">
|
||
<h2 id="controlsTitle" style="margin:0;font-size:1rem">Werkzeug</h2>
|
||
|
||
<div class="row">
|
||
<label class="chip"><input type="checkbox" id="sparkMode"> <span>Funken (Shift)</span></label>
|
||
<label class="chip"><input type="checkbox" id="bitMode"> <span>Bits „1“ setzen</span></label>
|
||
<label class="chip"><input type="checkbox" id="markers"> <span>Start/Stop-Marker</span></label>
|
||
<label class="chip"><input type="checkbox" id="frameOn" checked> <span>Crumblines-Frame</span></label>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<label>Strichstärke <input type="range" id="stroke" min="4" max="28" step="1" value="12"></label>
|
||
<label>Sampler
|
||
<select id="sampler">
|
||
<option value="raw">Raw Polyline</option>
|
||
<option value="quad">Quadratic</option>
|
||
<option value="cubic" selected>Catmull-Rom → Cubic</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<label>Farbe A <input type="color" id="colorA" value="#62d3a4"></label>
|
||
<label>Farbe B <input type="color" id="colorB" value="#ffd166"></label>
|
||
<label>Funken <input type="color" id="colorSpark" value="#e7eaf0"></label>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<label class="chip"><input type="radio" name="mode" value="A" checked> <span>Stroke A</span></label>
|
||
<label class="chip"><input type="radio" name="mode" value="B"> <span>Stroke B</span></label>
|
||
<label class="chip"><input type="radio" name="mode" value="GRAD"> <span>Verlauf A→B</span></label>
|
||
</div>
|
||
|
||
<div class="col" style="margin-top:6px">
|
||
<strong>Bezier-Editor</strong>
|
||
<div class="row">
|
||
<label class="chip"><input type="checkbox" id="editBezier"> <span>Bearbeiten</span></label>
|
||
<label class="chip"><input type="checkbox" id="linkHandles" checked> <span>Spiegel-Griffe</span></label>
|
||
<button class="btn" id="toBezier">Auswahl → Bezier</button>
|
||
<button class="btn" id="splitHandles">Anker: Split</button>
|
||
<button class="btn warn" id="delAnchor" disabled>Anker löschen</button>
|
||
</div>
|
||
<p class="muted">Klicke einen Stroke, dann <em>Bearbeiten</em>. Grüne Punkte = Anker, gelbe = Griffe. Ziehen. Doppelklick = Anker einfügen. <span class="kbd">Entf</span> löscht. <span class="kbd">L</span> spiegelt Griffe.</p>
|
||
</div>
|
||
|
||
<div>
|
||
<strong>Strokes</strong>
|
||
<ul id="strokeList" aria-label="Strich-Liste"></ul>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="btn" id="undo">Letzten Stroke löschen</button>
|
||
<button class="btn warn" id="clear">Alles löschen</button>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="btn" id="replay">▶︎ Replay</button>
|
||
<button class="btn" id="copySVG">SVG kopieren</button>
|
||
<button class="btn" id="downloadSVG">SVG speichern</button>
|
||
<button class="btn" id="downloadPNG">PNG speichern</button>
|
||
<button class="btn accent" id="copyJSON">Motion-JSON kopieren</button>
|
||
<button class="btn" id="downloadJSON">JSON speichern</button>
|
||
</div>
|
||
|
||
</aside>
|
||
|
||
<main class="canvasWrap" aria-label="Zeichenfläche">
|
||
<svg id="stage" viewBox="0 0 1200 800" role="img" aria-labelledby="svgTitle svgDesc">
|
||
<title id="svgTitle">Omi Omega – One-Liner</title>
|
||
<desc id="svgDesc">a→Spirale→y; Bits 1+1; Funken.</desc>
|
||
<defs>
|
||
<linearGradient id="gradA2B" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
<stop offset="0%" stop-color="#62d3a4" />
|
||
<stop offset="50%" stop-color="#62d3a4" />
|
||
<stop offset="51%" stop-color="#ffd166" />
|
||
<stop offset="100%" stop-color="#ffd166" />
|
||
</linearGradient>
|
||
<style>
|
||
.selected{filter:drop-shadow(0 0 4px #ffd166)}
|
||
</style>
|
||
</defs>
|
||
<rect id="frameRect" x="16.5" y="16.5" width="1167" height="767" fill="none" stroke="rgba(255,255,255,.2)" stroke-width="1.5" stroke-dasharray="6 10"/>
|
||
<g id="art">
|
||
<g id="strokes"></g>
|
||
<g id="bits"></g>
|
||
<g id="sparks" stroke="#e7eaf0" stroke-width="8" stroke-linecap="round"></g>
|
||
<g id="markersG"></g>
|
||
<g id="bezierUI"></g>
|
||
</g>
|
||
</svg>
|
||
</main>
|
||
</div>
|
||
|
||
<footer>
|
||
<span>Fixes: keine (0,0)-Griffe mehr; Drag startet sofort; UI blockiert keine Klicks.</span>
|
||
</footer>
|
||
|
||
<script>
|
||
(function(){
|
||
const stage = document.getElementById('stage');
|
||
const strokesG= document.getElementById('strokes');
|
||
const bitsG = document.getElementById('bits');
|
||
const sparksG = document.getElementById('sparks');
|
||
const markersG= document.getElementById('markersG');
|
||
const bezierUI= document.getElementById('bezierUI');
|
||
const frameRect = document.getElementById('frameRect');
|
||
const strokeList = document.getElementById('strokeList');
|
||
|
||
// Controls
|
||
const sparkMode = document.getElementById('sparkMode');
|
||
const bitMode = document.getElementById('bitMode');
|
||
const markersCb = document.getElementById('markers');
|
||
const frameOnCb = document.getElementById('frameOn');
|
||
const strokeInp = document.getElementById('stroke');
|
||
const samplerSel= document.getElementById('sampler');
|
||
const colorAInp = document.getElementById('colorA');
|
||
const colorBInp = document.getElementById('colorB');
|
||
const colorSInp = document.getElementById('colorSpark');
|
||
|
||
const editBezierCb = document.getElementById('editBezier');
|
||
const linkHandlesCb= document.getElementById('linkHandles');
|
||
const toBezierBtn = document.getElementById('toBezier');
|
||
const splitBtn = document.getElementById('splitHandles');
|
||
const delAnchorBtn = document.getElementById('delAnchor');
|
||
|
||
const undoBtn = document.getElementById('undo');
|
||
const clearBtn = document.getElementById('clear');
|
||
const copySVGBtn = document.getElementById('copySVG');
|
||
const dlSVGBtn = document.getElementById('downloadSVG');
|
||
const dlPNGBtn = document.getElementById('downloadPNG');
|
||
const copyJSONBtn = document.getElementById('copyJSON');
|
||
const dlJSONBtn = document.getElementById('downloadJSON');
|
||
const replayBtn = document.getElementById('replay');
|
||
|
||
let mode = 'A';
|
||
document.querySelectorAll('input[name="mode"]').forEach(r=>{
|
||
r.addEventListener('change', ()=>{ mode = document.querySelector('input[name="mode"]:checked').value; });
|
||
});
|
||
|
||
// State
|
||
let drawing = false;
|
||
let curStroke = null;
|
||
const strokes = [];
|
||
let t0 = null;
|
||
let playing = false;
|
||
let selectedStroke = null;
|
||
let selectedAnchor = {stroke:null, idx:-1};
|
||
let dragging = null; // {type:'anchor'|'h1'|'h2', stroke, idx, ox, oy, pid}
|
||
|
||
function now(){ return performance.now(); }
|
||
|
||
function svgPoint(evt){
|
||
const pt = stage.createSVGPoint();
|
||
pt.x = evt.clientX; pt.y = evt.clientY;
|
||
const ctm = stage.getScreenCTM().inverse();
|
||
const p = pt.matrixTransform(ctm);
|
||
return {x: Math.round(p.x), y: Math.round(p.y)};
|
||
}
|
||
|
||
function listRefresh(){
|
||
strokeList.innerHTML = '';
|
||
strokes.forEach((s,i)=>{
|
||
const li = document.createElement('li'); li.className='strokeItem';
|
||
const sw = document.createElement('input'); sw.type='range'; sw.min=4; sw.max=28; sw.step=1; sw.value=s.width; sw.title='Breite';
|
||
sw.addEventListener('input',()=>{ s.width=+sw.value; s.pathEl.setAttribute('stroke-width', s.width); });
|
||
const sel = document.createElement('select');
|
||
['A','B','GRAD'].forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; if(s.mode===v) o.selected=true; sel.appendChild(o); });
|
||
sel.addEventListener('change',()=>{ s.mode=sel.value; applyStrokeStyle(s); });
|
||
const edit = document.createElement('button'); edit.className='btn'; edit.textContent='Bearbeiten';
|
||
edit.addEventListener('click',()=>{ selectStroke(s); editBezierCb.checked = true; drawBezierUI(); });
|
||
const del = document.createElement('button'); del.className='btn'; del.textContent='✕';
|
||
del.addEventListener('click',()=>{ removeStroke(i); });
|
||
const meta = document.createElement('span'); meta.className='muted';
|
||
meta.textContent = `#${s.id} • ${Math.round(s.duration)}ms • ~${Math.round(s.length)}px${s.isBezier?' • Bezier':''}`;
|
||
li.append('Stroke', sel, sw, edit, del, meta);
|
||
strokeList.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function removeStroke(idx){
|
||
const s = strokes[idx];
|
||
if(!s) return;
|
||
s.pathEl.remove(); if(s.startMarker) s.startMarker.remove(); if(s.stopMarker) s.stopMarker.remove();
|
||
if(selectedStroke===s){ selectedStroke=null; clearBezierUI(); }
|
||
strokes.splice(idx,1);
|
||
listRefresh();
|
||
}
|
||
|
||
function createPathEl(){
|
||
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||
p.setAttribute('class','sline');
|
||
p.setAttribute('stroke-width', strokeInp.value);
|
||
p.addEventListener('pointerdown', (e)=>{
|
||
if(editBezierCb.checked){ e.stopPropagation(); const s = strokes.find(st=>st.pathEl===p); if(s){ selectStroke(s); drawBezierUI(); } }
|
||
});
|
||
strokesG.appendChild(p);
|
||
return p;
|
||
}
|
||
|
||
function applyStrokeStyle(s){
|
||
if(s.mode==='A'){ s.pathEl.setAttribute('stroke', colorAInp.value); }
|
||
else if(s.mode==='B'){ s.pathEl.setAttribute('stroke', colorBInp.value); }
|
||
else { s.pathEl.setAttribute('stroke','url(#gradA2B)'); }
|
||
}
|
||
|
||
// Samplers → SVG path "d"
|
||
function pathRaw(points){
|
||
if(points.length===0) return '';
|
||
const p0 = points[0];
|
||
let d = `M ${p0.x} ${p0.y}`;
|
||
for(let i=1;i<points.length;i++){ const p=points[i]; d += ` L ${p.x} ${p.y}`; }
|
||
return d;
|
||
}
|
||
function pathQuadratic(points){
|
||
if(points.length<2) return '';
|
||
let d = `M ${points[0].x} ${points[0].y}`;
|
||
for(let i=1;i<points.length-1;i++){
|
||
const p0 = points[i]; const p1 = points[i+1];
|
||
const mx = (p0.x + p1.x)/2; const my = (p0.y + p1.y)/2;
|
||
d += ` Q ${p0.x} ${p0.y} ${mx} ${my}`;
|
||
}
|
||
const last = points[points.length-1]; d += ` L ${last.x} ${last.y}`;
|
||
return d;
|
||
}
|
||
function pathCubic(points){
|
||
if(points.length<2) return '';
|
||
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
|
||
const p=(i)=>points[ clamp(i,0,points.length-1) ];
|
||
let d=`M ${points[0].x} ${points[0].y}`;
|
||
for(let i=0;i<points.length-1;i++){
|
||
const p0=p(i-1), p1=p(i), p2=p(i+1), p3=p(i+2);
|
||
const c1x = p1.x + (p2.x - p0.x)/6, c1y = p1.y + (p2.y - p0.y)/6;
|
||
const c2x = p2.x - (p3.x - p1.x)/6, c2y = p2.y - (p3.y - p1.y)/6;
|
||
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
|
||
}
|
||
return d;
|
||
}
|
||
function toPathD(s){
|
||
if(s.isBezier) return pathFromAnchors(s.anchors);
|
||
const pts = s.points;
|
||
const smp = s.sampler || samplerSel.value;
|
||
if(smp==='raw') return pathRaw(pts);
|
||
if(smp==='cubic') return pathCubic(pts);
|
||
return pathQuadratic(pts);
|
||
}
|
||
|
||
function startStroke(e){
|
||
if(editBezierCb.checked){ return; } // während Edit nicht zeichnen
|
||
if(bitMode.checked){ placeBit(e); return; }
|
||
if(sparkMode.checked || e.shiftKey){ placeSpark(e); return; }
|
||
drawing = true;
|
||
if(t0===null) t0 = now();
|
||
const t = now() - t0;
|
||
const pt = svgPoint(e);
|
||
const pathEl = createPathEl();
|
||
curStroke = { id: Date.now()%1e7, mode, colorA: colorAInp.value, colorB: colorBInp.value, width:+strokeInp.value, points:[{...pt,t}], pathEl, start:t, duration:0, length:0, sampler:samplerSel.value, isBezier:false };
|
||
applyStrokeStyle(curStroke);
|
||
updatePath(curStroke);
|
||
if(markersCb.checked){ curStroke.startMarker = mark(pt.x, pt.y, 'start'); }
|
||
window.addEventListener('pointerup', endStroke, {once:true});
|
||
}
|
||
function moveStroke(e){
|
||
if(!drawing || !curStroke) return;
|
||
const p = svgPoint(e);
|
||
const t = now() - t0;
|
||
const last = curStroke.points[curStroke.points.length-1];
|
||
if(!last || Math.hypot(p.x-last.x, p.y-last.y) > 2){
|
||
curStroke.points.push({...p,t});
|
||
updatePath(curStroke);
|
||
}
|
||
}
|
||
function endStroke(){
|
||
if(!curStroke) return;
|
||
drawing = false;
|
||
const pts = curStroke.points;
|
||
curStroke.duration = pts.length? (pts[pts.length-1].t - pts[0].t) : 0;
|
||
curStroke.length = polyLen(pts);
|
||
if(markersCb.checked){ const last=pts[pts.length-1]; curStroke.stopMarker = mark(last.x,last.y,'stop'); }
|
||
strokes.push(curStroke); curStroke = null; listRefresh();
|
||
}
|
||
|
||
function mark(x,y,type){
|
||
const r = 7; const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||
c.setAttribute('cx',x); c.setAttribute('cy',y); c.setAttribute('r',r);
|
||
c.setAttribute('class', type==='start'?'markerStart':'markerStop');
|
||
markersG.appendChild(c); return c;
|
||
}
|
||
|
||
function polyLen(pts){ let L=0; for(let i=1;i<pts.length;i++){ const dx=pts[i].x-pts[i-1].x, dy=pts[i].y-pts[i-1].y; L += Math.hypot(dx,dy);} return L; }
|
||
|
||
function updatePath(s){
|
||
s.pathEl.setAttribute('d', toPathD(s));
|
||
s.pathEl.setAttribute('stroke-width', s.width);
|
||
}
|
||
|
||
function placeSpark(e){
|
||
const p = svgPoint(e);
|
||
const dx = 10, dy = -10;
|
||
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||
l.setAttribute('x1', p.x); l.setAttribute('y1', p.y);
|
||
l.setAttribute('x2', p.x+dx); l.setAttribute('y2', p.y+dy);
|
||
l.setAttribute('stroke', colorSInp.value);
|
||
l.setAttribute('stroke-width', Math.max(2, Math.round(parseInt(strokeInp.value,10)*0.66)));
|
||
l.setAttribute('stroke-linecap','round');
|
||
sparksG.appendChild(l);
|
||
}
|
||
function placeBit(e){
|
||
const p = svgPoint(e);
|
||
const h = Math.max(18, parseInt(strokeInp.value,10)*1.2);
|
||
const w = Math.max(6, parseInt(strokeInp.value,10)*0.8);
|
||
const line1 = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||
line1.setAttribute('x1', p.x); line1.setAttribute('y1', p.y-h/2);
|
||
line1.setAttribute('x2', p.x); line1.setAttribute('y2', p.y+h/2);
|
||
line1.setAttribute('stroke', colorBInp.value);
|
||
line1.setAttribute('stroke-width', w);
|
||
line1.setAttribute('stroke-linecap', 'round');
|
||
bitsG.appendChild(line1);
|
||
}
|
||
|
||
// --- Bezier conversion & editing ---
|
||
// FIX: build anchors with valid handles only; no (0,0) placeholders
|
||
function catmullToAnchors(points){
|
||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
|
||
const P=(i)=>points[ clamp(i,0,points.length-1) ];
|
||
const A=[];
|
||
for(let i=0;i<points.length-1;i++){
|
||
const p0=P(i-1), p1=P(i), p2=P(i+1), p3=P(i+2);
|
||
const c1 = {x: p1.x + (p2.x - p0.x)/6, y: p1.y + (p2.y - p0.y)/6};
|
||
const c2 = {x: p2.x - (p3.x - p1.x)/6, y: p2.y - (p3.y - p1.y)/6};
|
||
if(i===0){
|
||
A.push({x:p1.x,y:p1.y,h1:{x:p1.x,y:p1.y},h2:{x:c1.x,y:c1.y},split:false});
|
||
}else{
|
||
// update previous anchor's outgoing handle
|
||
A[A.length-1].h2 = {x:c1.x,y:c1.y};
|
||
}
|
||
// next anchor for p2
|
||
const next = {x:p2.x,y:p2.y,h1:{x:c2.x,y:c2.y},h2:{x:p2.x,y:p2.y},split:false};
|
||
A.push(next);
|
||
}
|
||
return A;
|
||
}
|
||
|
||
function pathFromAnchors(A){
|
||
if(!A || A.length<2) return '';
|
||
let d = `M ${A[0].x} ${A[0].y}`;
|
||
for(let i=0;i<A.length-1;i++){
|
||
const a=A[i], b=A[i+1];
|
||
d += ` C ${a.h2.x} ${a.h2.y}, ${b.h1.x} ${b.h1.y}, ${b.x} ${b.y}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function selectStroke(s){
|
||
selectedStroke = s;
|
||
strokes.forEach(st=> st.pathEl.classList.toggle('selected', st===s));
|
||
}
|
||
|
||
function clearBezierUI(){ bezierUI.innerHTML=''; selectedAnchor={stroke:null, idx:-1}; delAnchorBtn.disabled = true; }
|
||
function drawBezierUI(){
|
||
clearBezierUI();
|
||
const s = selectedStroke;
|
||
if(!editBezierCb.checked || !s || !s.isBezier) return;
|
||
s.anchors.forEach((a,idx)=>{
|
||
// handle lines
|
||
bezierUI.append(line(a.x, a.y, a.h1.x, a.h1.y, 'handleLine'));
|
||
bezierUI.append(line(a.x, a.y, a.h2.x, a.h2.y, 'handleLine'));
|
||
// handles
|
||
const h1 = circle(a.h1.x, a.h1.y, 5, 'handle', (e)=>startDrag('h1', s, idx, e));
|
||
const h2 = circle(a.h2.x, a.h2.y, 5, 'handle', (e)=>startDrag('h2', s, idx, e));
|
||
bezierUI.append(h1, h2);
|
||
// anchor
|
||
const an = circle(a.x, a.y, 6.5, 'anchor', (e)=>{ startDrag('anchor', s, idx, e) });
|
||
an.addEventListener('dblclick', (e)=>{ e.stopPropagation(); insertAnchorAt(s, idx); });
|
||
an.addEventListener('pointerdown', ()=>{ selectedAnchor={stroke:s,idx}; delAnchorBtn.disabled=false; });
|
||
bezierUI.append(an);
|
||
});
|
||
}
|
||
|
||
function line(x1,y1,x2,y2,cls){
|
||
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||
l.setAttribute('x1',x1); l.setAttribute('y1',y1);
|
||
l.setAttribute('x2',x2); l.setAttribute('y2',y2);
|
||
l.setAttribute('class',cls);
|
||
return l;
|
||
}
|
||
function circle(x,y,r,cls,onDown){
|
||
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||
c.setAttribute('cx',x); c.setAttribute('cy',y); c.setAttribute('r',r);
|
||
c.setAttribute('class',cls);
|
||
c.style.touchAction = 'none';
|
||
c.addEventListener('pointerdown', (e)=>{ e.stopPropagation(); onDown(e); });
|
||
return c;
|
||
}
|
||
|
||
// FIX: start drag immediately on current event + proper pointer capture
|
||
function startDrag(type, s, idx, ev){
|
||
ev.preventDefault();
|
||
const a = s.anchors[idx];
|
||
const p0 = svgPoint(ev);
|
||
dragging = {type, stroke:s, idx, ox:p0.x, oy:p0.y, pid:ev.pointerId};
|
||
try { stage.setPointerCapture(ev.pointerId); } catch(e){}
|
||
const move = (e)=>{
|
||
if(!dragging) return;
|
||
const pos = svgPoint(e);
|
||
const dx = pos.x - dragging.ox, dy = pos.y - dragging.oy;
|
||
dragging.ox = pos.x; dragging.oy = pos.y;
|
||
if(type==='anchor'){
|
||
a.x+=dx; a.y+=dy; a.h1.x+=dx; a.h1.y+=dy; a.h2.x+=dx; a.h2.y+=dy;
|
||
}else if(type==='h1'){
|
||
a.h1.x+=dx; a.h1.y+=dy;
|
||
if(linkHandlesCb.checked && !a.split){
|
||
a.h2.x = a.x - (a.h1.x - a.x);
|
||
a.h2.y = a.y - (a.h1.y - a.y);
|
||
}
|
||
}else if(type==='h2'){
|
||
a.h2.x+=dx; a.h2.y+=dy;
|
||
if(linkHandlesCb.checked && !a.split){
|
||
a.h1.x = a.x - (a.h2.x - a.x);
|
||
a.h1.y = a.y - (a.h2.y - a.y);
|
||
}
|
||
}
|
||
s.pathEl.setAttribute('d', pathFromAnchors(s.anchors));
|
||
drawBezierUI();
|
||
};
|
||
const up = (e)=>{
|
||
try { stage.releasePointerCapture(ev.pointerId); } catch(_) {}
|
||
window.removeEventListener('pointermove', move);
|
||
dragging=null;
|
||
};
|
||
window.addEventListener('pointermove', move, {passive:true});
|
||
window.addEventListener('pointerup', up, {once:true});
|
||
}
|
||
|
||
function insertAnchorAt(s, idx){
|
||
const A = s.anchors;
|
||
if(idx>=A.length-1) return;
|
||
const a = A[idx], b = A[idx+1];
|
||
const mid = {x:(a.x+b.x)/2, y:(a.y+b.y)/2};
|
||
const dir = {x:(b.x-a.x), y:(b.y-a.y)};
|
||
const scale = 0.33;
|
||
const hLen = {x:dir.x*scale, y:dir.y*scale};
|
||
const newA = {
|
||
x: mid.x, y: mid.y,
|
||
h1: {x: mid.x - hLen.x*0.5, y: mid.y - hLen.y*0.5},
|
||
h2: {x: mid.x + hLen.x*0.5, y: mid.y + hLen.y*0.5},
|
||
split:false
|
||
};
|
||
A.splice(idx+1,0,newA);
|
||
s.pathEl.setAttribute('d', pathFromAnchors(A));
|
||
drawBezierUI();
|
||
}
|
||
|
||
function deleteSelectedAnchor(){
|
||
const {stroke:s, idx} = selectedAnchor;
|
||
if(!s || idx<0) return;
|
||
if(s.anchors.length<=2) return;
|
||
s.anchors.splice(idx,1);
|
||
selectedAnchor={stroke:null,idx:-1};
|
||
delAnchorBtn.disabled=true;
|
||
s.pathEl.setAttribute('d', pathFromAnchors(s.anchors));
|
||
drawBezierUI();
|
||
}
|
||
|
||
function convertToBezier(s){
|
||
if(s.isBezier) return;
|
||
if(s.points.length<2) return;
|
||
s.anchors = catmullToAnchors(s.points);
|
||
s.isBezier = true;
|
||
s.pathEl.setAttribute('d', pathFromAnchors(s.anchors));
|
||
listRefresh();
|
||
}
|
||
|
||
toBezierBtn.addEventListener('click', ()=>{ if(selectedStroke) { convertToBezier(selectedStroke); drawBezierUI(); } });
|
||
splitBtn.addEventListener('click', ()=>{ if(selectedStroke && selectedAnchor.idx>=0){ const a=selectedStroke.anchors[selectedAnchor.idx]; a.split=!a.split; drawBezierUI(); }});
|
||
delAnchorBtn.addEventListener('click', deleteSelectedAnchor);
|
||
window.addEventListener('keydown', (e)=>{
|
||
if(e.key==='Delete' || e.key==='Backspace'){ if(editBezierCb.checked) { deleteSelectedAnchor(); } }
|
||
if(e.key==='l' || e.key==='L'){ linkHandlesCb.checked = !linkHandlesCb.checked; }
|
||
});
|
||
|
||
// Double-click on path to insert anchor near click
|
||
strokesG.addEventListener('dblclick', (e)=>{
|
||
if(!editBezierCb.checked || !selectedStroke || !selectedStroke.isBezier) return;
|
||
const p = svgPoint(e);
|
||
let bestI=0, bestD=1e9, A=selectedStroke.anchors;
|
||
for(let i=0;i<A.length-1;i++){
|
||
const a=A[i], b=A[i+1];
|
||
const d = Math.hypot(p.x-(a.x+b.x)/2, p.y-(a.y+b.y)/2);
|
||
if(d<bestD){ bestD=d; bestI=i; }
|
||
}
|
||
insertAnchorAt(selectedStroke, bestI);
|
||
});
|
||
|
||
// Export / Import / Replay (minimal for this fix)
|
||
function exportSVGString(){
|
||
frameRect.style.display = frameOnCb.checked ? 'block' : 'none';
|
||
const clone = stage.cloneNode(true);
|
||
// remove Bezier UI
|
||
const ui = clone.querySelector('#bezierUI'); if(ui) ui.remove();
|
||
const s = new XMLSerializer().serializeToString(clone);
|
||
return `<?xml version="1.0" encoding="UTF-8"?>\n${s}`;
|
||
}
|
||
function motionJSON(){
|
||
const items = strokes.map(s=>{
|
||
const o = {
|
||
id: s.id, mode: s.mode, colorA: s.colorA, colorB: s.colorB, width: s.width, sampler: s.sampler,
|
||
startMs: s.start||0, durationMs: s.duration||0, lengthPx: s.length||0,
|
||
};
|
||
if(s.isBezier){
|
||
o.isBezier = true;
|
||
o.anchors = s.anchors.map(a=>({x:a.x,y:a.y,h1:{x:a.h1.x,y:a.h1.y},h2:{x:a.h2.x,y:a.h2.y},split:!!a.split}));
|
||
}else{
|
||
o.points = (s.points||[]).map(p=>({x:p.x,y:p.y,tMs: Math.round(p.t)}));
|
||
}
|
||
return o;
|
||
});
|
||
return JSON.stringify({strokes:items}, null, 2);
|
||
}
|
||
|
||
document.getElementById('undo').addEventListener('click', ()=>{ if(strokes.length){ removeStroke(strokes.length-1); }});
|
||
document.getElementById('clear').addEventListener('click', ()=>{
|
||
strokes.length=0; strokesG.innerHTML=''; bitsG.innerHTML=''; sparksG.innerHTML=''; markersG.innerHTML=''; clearBezierUI(); listRefresh(); t0=null; });
|
||
|
||
copySVGBtn.addEventListener('click', async ()=>{ const svg=exportSVGString(); await navigator.clipboard.writeText(svg); alert('SVG kopiert'); });
|
||
dlSVGBtn.addEventListener('click', ()=>{ const svg=exportSVGString(); const blob=new Blob([svg],{type:'image/svg+xml'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega-oneliner.svg'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
|
||
dlPNGBtn.addEventListener('click', ()=>{ const svg=exportSVGString(); svgToPng(svg, 'omi-omega-oneliner.png'); });
|
||
copyJSONBtn.addEventListener('click', async ()=>{ const js=motionJSON(); await navigator.clipboard.writeText(js); alert('Motion-JSON kopiert'); });
|
||
dlJSONBtn.addEventListener('click', ()=>{ const js=motionJSON(); const blob=new Blob([js],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega_motion.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
|
||
replayBtn.addEventListener('click', startReplay);
|
||
|
||
// Stage events
|
||
stage.addEventListener('pointerdown', startStroke);
|
||
stage.addEventListener('pointermove', moveStroke);
|
||
window.addEventListener('pointerup', ()=>{ drawing=false; });
|
||
|
||
// init
|
||
frameRect.style.display = frameOnCb.checked ? 'block':'none';
|
||
|
||
// Replay
|
||
function startReplay(){
|
||
if(!strokes.length) return;
|
||
playing = true;
|
||
markersG.style.opacity = 0.25; bezierUI.style.opacity = 0.15;
|
||
strokes.forEach(s=> s.pathEl.setAttribute('d',''));
|
||
const total = strokes.reduce((m,s)=>Math.max(m, s.isBezier?1500:(s.points?.length ? s.points[s.points.length-1].t : 0)), 0);
|
||
const startWall = performance.now();
|
||
function step(){
|
||
if(!playing) return;
|
||
const t = performance.now() - startWall;
|
||
strokes.forEach(s=>{
|
||
if(s.isBezier){
|
||
const A=s.anchors;
|
||
const k = Math.min(A.length-1, Math.floor((t/total)*(A.length-1)) );
|
||
let d = `M ${A[0].x} ${A[0].y}`;
|
||
for(let i=0;i<k;i++){ const a=A[i], b=A[i+1]; d += ` C ${a.h2.x} ${a.h2.y}, ${b.h1.x} ${b.h1.y}, ${b.x} ${b.y}`; }
|
||
s.pathEl.setAttribute('d', d);
|
||
applyStrokeStyle(s);
|
||
s.pathEl.setAttribute('stroke-width', s.width);
|
||
}else{
|
||
const pts = s.points.filter(p => p.t <= t);
|
||
if(pts.length >= 2){
|
||
s.pathEl.setAttribute('stroke-width', s.width);
|
||
applyStrokeStyle(s);
|
||
const smp = s.sampler || samplerSel.value;
|
||
s.pathEl.setAttribute('d', smp==='raw'?pathRaw(pts): (smp==='cubic'?pathCubic(pts):pathQuadratic(pts)));
|
||
}
|
||
}
|
||
});
|
||
if(t < total) requestAnimationFrame(step);
|
||
else { playing=false; markersG.style.opacity = 1; bezierUI.style.opacity = 1; strokes.forEach(updatePath); }
|
||
}
|
||
requestAnimationFrame(step);
|
||
}
|
||
|
||
function svgToPng(svgString, filename){
|
||
const img = new Image();
|
||
const svgBlob = new Blob([svgString], {type:'image/svg+xml;charset=utf-8'});
|
||
const url = URL.createObjectURL(svgBlob);
|
||
img.onload = function(){
|
||
const canvas = document.createElement('canvas');
|
||
const vb = stage.viewBox.baseVal;
|
||
canvas.width = vb.width; canvas.height = vb.height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0);
|
||
canvas.toBlob((blob)=>{
|
||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click();
|
||
});
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|