629 lines
30 KiB
HTML
629 lines
30 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 Pen + Editor</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:380px 1fr;gap:16px;padding:16px}
|
||
@media (max-width:1100px){.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;touch-action:none}
|
||
.badge{font-size:.75rem;color:#0b1114;background:var(--accent);border-radius:999px;padding:2px 8px}
|
||
.muted{color:var(--muted)}
|
||
.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}
|
||
.ghost{fill:none;stroke:#ffffff55;stroke-width:2;stroke-dasharray:6 8}
|
||
#bezierUI{ pointer-events:none }
|
||
.selected{filter:drop-shadow(0 0 4px #ffd166)}
|
||
.hint{font-size:.85rem;color:#cfd5e3;line-height:1.3}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>One‑Liner Painter • <span class="badge">Bezier-Pen</span> + Editor • Replay • SVG/PNG/JSON</h1>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<aside class="panel">
|
||
<h2 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“</span></label>
|
||
<label class="chip"><input type="checkbox" id="markers"> <span>Start/Stop</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‑Pen (Anker setzen)</strong>
|
||
<div class="row">
|
||
<label class="chip"><input type="checkbox" id="penMode"> <span>Pen aktiv</span></label>
|
||
<button class="btn" id="finishPen" disabled>Fertig (↵)</button>
|
||
<span class="muted">Shift: Winkel snap • Alt: Ecke • ⌫: Letzten Anchor löschen</span>
|
||
</div>
|
||
</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="hint">Klicke einen Stroke → <em>Bearbeiten</em>. Doppelklick auf Pfad fügt einen Anker. <span class="kbd">L</span> toggelt Spiegel‑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>
|
||
</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>
|
||
<path id="ghost" class="ghost" d=""/>
|
||
<g id="bezierUI"></g>
|
||
</g>
|
||
</svg>
|
||
</main>
|
||
</div>
|
||
|
||
<footer>
|
||
<span>Neu: richtiger Bezier‑Pen wie in Illustrator/Inkscape (Anker setzen & ziehen).</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 ghost = document.getElementById('ghost');
|
||
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 penModeCb = document.getElementById('penMode');
|
||
const finishPenBtn = document.getElementById('finishPen');
|
||
|
||
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 selectedStroke = null;
|
||
let selectedAnchor = {stroke:null, idx:-1};
|
||
let dragging = null; // edit-drag
|
||
|
||
// Pen state
|
||
let penActive = false;
|
||
let penStroke = null; // {isBezier:true, anchors:[], pathEl,...}
|
||
let penDragging = false;
|
||
let placingFirst = false;
|
||
let placingNext = false;
|
||
let penStartPt = null;
|
||
|
||
function now(){ return performance.now(); }
|
||
function svgPoint(evt){
|
||
const pt = stage.createSVGPoint();
|
||
pt.x = evt.clientX; pt.y = evt.clientY;
|
||
const p = pt.matrixTransform(stage.getScreenCTM().inverse());
|
||
return {x: Math.round(p.x), y: Math.round(p.y)};
|
||
}
|
||
|
||
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 && !penActive){ 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)'); }
|
||
}
|
||
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 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} • ${s.isBezier? 'Bezier' : (s.points?.length||0)+' pts'}`;
|
||
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 selectStroke(s){
|
||
selectedStroke = s;
|
||
strokes.forEach(st=> st.pathEl.classList.toggle('selected', st===s));
|
||
}
|
||
|
||
// -------- Freehand --------
|
||
function toPathD(s){
|
||
if(s.isBezier) return pathFromAnchors(s.anchors);
|
||
const pts = s.points;
|
||
const smp = s.sampler || samplerSel.value;
|
||
if(pts.length===0) return '';
|
||
if(smp==='raw'){
|
||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||
for(let i=1;i<pts.length;i++){ d+=` L ${pts[i].x} ${pts[i].y}`; } return d;
|
||
} else if(smp==='cubic'){
|
||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); const P=(i)=>pts[ clamp(i,0,pts.length-1) ];
|
||
let d=`M ${pts[0].x} ${pts[0].y}`;
|
||
for(let i=0;i<pts.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;
|
||
} else {
|
||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||
for(let i=1;i<pts.length-1;i++){ const p0=pts[i], p1=pts[i+1]; const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2; d += ` Q ${p0.x} ${p0.y} ${mx} ${my}`; }
|
||
const last=pts[pts.length-1]; d += ` L ${last.x} ${last.y}`; return d;
|
||
}
|
||
}
|
||
function updatePath(s){ s.pathEl.setAttribute('d', toPathD(s)); s.pathEl.setAttribute('stroke-width', s.width); }
|
||
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 startStroke(e){
|
||
if(penActive || editBezierCb.checked){ return; }
|
||
if(bitMode.checked){ placeBit(e); return; }
|
||
if(sparkMode.checked || e.shiftKey){ placeSpark(e); return; }
|
||
const t = now();
|
||
const pt = svgPoint(e);
|
||
const pathEl = createPathEl();
|
||
curStroke = { id: Date.now()%1e7, mode, colorA: colorAInp.value, colorB: colorBInp.value, width:+strokeInp.value, points:[pt], 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'); }
|
||
drawing = true;
|
||
}
|
||
stage.addEventListener('pointermove', (e)=>{
|
||
if(!drawing || !curStroke) return;
|
||
const p = svgPoint(e);
|
||
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);
|
||
updatePath(curStroke);
|
||
}
|
||
});
|
||
window.addEventListener('pointerup', ()=>{
|
||
if(drawing && curStroke){
|
||
const pts = curStroke.points;
|
||
curStroke.duration = 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();
|
||
}
|
||
drawing=false;
|
||
});
|
||
|
||
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 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);
|
||
}
|
||
|
||
// Editor helpers
|
||
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)=>{
|
||
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'));
|
||
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);
|
||
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';
|
||
if(onDown) c.addEventListener('pointerdown', (e)=>{ e.stopPropagation(); onDown(e); });
|
||
return c;
|
||
}
|
||
function startDrag(type, s, idx, ev){
|
||
ev.preventDefault();
|
||
const a = s.anchors[idx];
|
||
const p0 = svgPoint(ev);
|
||
const dragging = {type, stroke:s, idx, ox:p0.x, oy:p0.y, pid:ev.pointerId};
|
||
try { stage.setPointerCapture(ev.pointerId); } catch(e){}
|
||
const move = (e)=>{
|
||
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 = ()=>{
|
||
try { stage.releasePointerCapture(ev.pointerId); } catch(_) {}
|
||
window.removeEventListener('pointermove', move);
|
||
};
|
||
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 hLen = {x:dir.x*0.33, y:dir.y*0.33};
|
||
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();
|
||
}
|
||
delAnchorBtn.addEventListener('click', ()=>{
|
||
const s=selectedStroke, idx=selectedAnchor.idx;
|
||
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();
|
||
});
|
||
toBezierBtn.addEventListener('click', ()=>{
|
||
if(selectedStroke && !selectedStroke.isBezier){
|
||
const s=selectedStroke; const pts=s.points;
|
||
if(!pts || pts.length<2) return;
|
||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); const P=(i)=>pts[ clamp(i,0,pts.length-1) ];
|
||
const A=[];
|
||
for(let i=0;i<pts.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 { A[A.length-1].h2 = {x:c1.x,y:c1.y}; }
|
||
A.push({x:p2.x,y:p2.y,h1:{x:c2.x,y:c2.y},h2:{x:p2.x,y:p2.y},split:false});
|
||
}
|
||
s.anchors=A; s.isBezier=true;
|
||
s.pathEl.setAttribute('d', pathFromAnchors(A));
|
||
listRefresh(); drawBezierUI();
|
||
}
|
||
});
|
||
splitBtn.addEventListener('click', ()=>{
|
||
if(selectedStroke && selectedAnchor.idx>=0){ const a=selectedStroke.anchors[selectedAnchor.idx]; a.split=!a.split; drawBezierUI(); }
|
||
});
|
||
window.addEventListener('keydown', (e)=>{
|
||
if((e.key==='Delete'||e.key==='Backspace') && penActive){ penBackspace(); }
|
||
if((e.key==='Delete'||e.key==='Backspace') && editBezierCb.checked){ delAnchorBtn.click(); }
|
||
if(e.key==='l' || e.key==='L'){ linkHandlesCb.checked = !linkHandlesCb.checked; }
|
||
if(e.key==='Enter' && penActive){ finishPen(); }
|
||
});
|
||
|
||
// Pen tool
|
||
penModeCb.addEventListener('change', ()=>{
|
||
const on = penModeCb.checked;
|
||
if(on){ editBezierCb.checked=false; penActive=true; ghost.setAttribute('d',''); deselectAll(); }
|
||
else { penActive=false; cancelPen(); }
|
||
finishPenBtn.disabled = !on;
|
||
});
|
||
finishPenBtn.addEventListener('click', finishPen);
|
||
|
||
function startPen(e){
|
||
const p = svgPoint(e);
|
||
if(!penStroke){
|
||
penStroke = { id: Date.now()%1e7, mode, width:+strokeInp.value, colorA: colorAInp.value, colorB: colorBInp.value,
|
||
isBezier:true, anchors:[], pathEl:createPathEl() };
|
||
applyStrokeStyle(penStroke);
|
||
const a = {x:p.x,y:p.y,h1:{x:p.x,y:p.y},h2:{x:p.x,y:p.y},split:false};
|
||
penStroke.anchors.push(a);
|
||
placingFirst=true; penDragging=true; penStartPt=p;
|
||
try{ stage.setPointerCapture(e.pointerId); }catch(_){}}
|
||
else {
|
||
const a = {x:p.x,y:p.y,h1:{x:p.x,y:p.y},h2:{x:p.x,y:p.y},split:false};
|
||
penStroke.anchors.push(a);
|
||
placingNext=true; penDragging=true;
|
||
try{ stage.setPointerCapture(e.pointerId); }catch(_){}}
|
||
}
|
||
function movePen(e){
|
||
if(!penStroke || !penDragging) return;
|
||
const p = svgPoint(e);
|
||
const A = penStroke.anchors;
|
||
if(placingFirst){
|
||
const start=A[0];
|
||
let v = {x: p.x - start.x, y: p.y - start.y};
|
||
if(e.shiftKey){
|
||
const ang=Math.atan2(v.y,v.x), step=Math.PI/12; const a=Math.round(ang/step)*step; const L=Math.hypot(v.x,v.y); v={x:Math.cos(a)*L,y:Math.sin(a)*L};
|
||
}
|
||
start.h2 = {x:start.x+v.x, y:start.y+v.y};
|
||
} else if(placingNext){
|
||
const last=A[A.length-1], prev=A[A.length-2];
|
||
let v = {x: p.x - last.x, y: p.y - last.y};
|
||
if(e.shiftKey){
|
||
const ang=Math.atan2(v.y,v.x), step=Math.PI/12; const a=Math.round(ang/step)*step; const L=Math.hypot(v.x,v.y); v={x:Math.cos(a)*L,y:Math.sin(a)*L};
|
||
}
|
||
last.h1 = {x:last.x+v.x, y:last.y+v.y};
|
||
if(e.altKey){ last.split=true; last.h2={x:last.x,y:last.y}; }
|
||
else { last.split=false; last.h2 = {x:last.x - v.x, y:last.y - v.y}; }
|
||
// previous outgoing 1/3 along segment
|
||
const seg = {x:last.x - prev.x, y:last.y - prev.y};
|
||
const L = Math.hypot(seg.x,seg.y); if(L>0){
|
||
const n={x:seg.x/L,y:seg.y/L}; const k=L/3;
|
||
prev.h2 = {x: prev.x + n.x*k, y: prev.y + n.y*k};
|
||
}
|
||
}
|
||
ghost.setAttribute('d', pathFromAnchors(A));
|
||
penStroke.pathEl.setAttribute('d', pathFromAnchors(A));
|
||
applyStrokeStyle(penStroke);
|
||
penStroke.pathEl.setAttribute('stroke-width', penStroke.width);
|
||
}
|
||
function endPen(e){ penDragging=false; placingFirst=false; placingNext=false; ghost.setAttribute('d',''); }
|
||
function finishPen(){ if(!penStroke) return; strokes.push(penStroke); listRefresh(); penStroke=null; penStartPt=null; ghost.setAttribute('d',''); }
|
||
function cancelPen(){ if(penStroke){ penStroke.pathEl.remove(); } penStroke=null; penStartPt=null; ghost.setAttribute('d',''); }
|
||
function penBackspace(){ if(!penStroke) return; const A=penStroke.anchors; if(A.length<=1){ cancelPen(); } else { A.pop(); penStroke.pathEl.setAttribute('d', pathFromAnchors(A)); } }
|
||
|
||
// Stage routing
|
||
stage.addEventListener('pointerdown', (e)=>{ if(penModeCb.checked) startPen(e); else startStroke(e); });
|
||
stage.addEventListener('pointermove', (e)=>{ if(penModeCb.checked) movePen(e); });
|
||
window.addEventListener('pointerup', (e)=>{ if(penModeCb.checked) endPen(e); });
|
||
|
||
// Misc
|
||
function exportSVGString(){
|
||
frameRect.style.display = frameOnCb.checked ? 'block' : 'none';
|
||
const clone = stage.cloneNode(true);
|
||
const ui = clone.querySelector('#bezierUI'); if(ui) ui.remove();
|
||
const gh = clone.querySelector('#ghost'); if(gh) gh.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 || 'cubic' };
|
||
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}));
|
||
}
|
||
return o;
|
||
});
|
||
return JSON.stringify({strokes:items}, null, 2);
|
||
}
|
||
document.getElementById('copySVG').addEventListener('click', async ()=>{ const svg=exportSVGString(); await navigator.clipboard.writeText(svg); alert('SVG kopiert'); });
|
||
document.getElementById('downloadSVG').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); });
|
||
document.getElementById('downloadPNG').addEventListener('click', ()=>{ const svg=exportSVGString(); svgToPng(svg, 'omi-omega-oneliner.png'); });
|
||
document.getElementById('copyJSON').addEventListener('click', async ()=>{ const js=motionJSON(); await navigator.clipboard.writeText(js); alert('Motion‑JSON kopiert'); });
|
||
document.getElementById('downloadJSON').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); });
|
||
document.getElementById('replay').addEventListener('click', ()=>{
|
||
if(!strokes.length) return;
|
||
strokes.forEach(s=> s.pathEl.setAttribute('d',''));
|
||
const total=1500; const t0=performance.now();
|
||
const step=()=>{
|
||
const t=performance.now()-t0;
|
||
strokes.forEach(s=>{
|
||
if(!s.isBezier) return;
|
||
const A=s.anchors, n=A.length-1; const k=Math.max(1, Math.floor((t/total)*n));
|
||
let d=`M ${A[0].x} ${A[0].y}`;
|
||
for(let i=0;i<k && i<n;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);
|
||
});
|
||
if(t<total) requestAnimationFrame(step); else { 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;
|
||
}
|
||
|
||
// Basic toggles
|
||
frameRect.style.display = document.getElementById('frameOn').checked ? 'block' : 'none';
|
||
document.getElementById('frameOn').addEventListener('change', (e)=>{ frameRect.style.display = e.target.checked ? 'block' : 'none'; });
|
||
|
||
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=''; ghost.setAttribute('d',''); clearBezierUI(); listRefresh(); });
|
||
|
||
function deselectAll(){ selectedStroke=null; strokes.forEach(st=> st.pathEl.classList.remove('selected')); clearBezierUI(); }
|
||
|
||
})();</script>
|
||
</body>
|
||
</html>
|