Simple SVG Editor
<link href="//code.jquery.com/ui/1.11.2/themes/smoothness/jquery-ui.css" rel="stylesheet" />
body { font-family: sans-serif; }
h1 { font-size: 1.5em; margin: 0 0 0.5em 0; }
.svg-editor {
width: 100%;
height: 20em;
border: 1px solid #bbb;
}
#svgOutput {
border: 2px dashed silver;
display: table;
background: no-repeat;
//Get rid of space between svg and bottom border
//(but don't interfere with nested <svg>s):
>svg {
display: block;
}
}
.tool-row {
white-space: nowrap;
margin-top: 1em;
&.swipe-arc, &.split-segs {
>div {
display: inline-block;
margin-right: 0.5em;
}
input {
width: 5em;
margin-left: 0.2em;
}
}
&.downloaders > * {
vertical-align: top;
}
.export-png {
display: inline-block;
margin-left: 1em;
#export-png-width {
width: 3.5em;
}
> * {
display: block;
}
}
}
<script src="https://codepen.io/Sphinxxxx/pen/VejGLv"></script>
<script src="//code.jquery.com/jquery-1.11.0.js"></script>
<script src="//code.jquery.com/ui/1.11.2/jquery-ui.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ace.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ext-language_tools.js"></script>
<script src="//cdn.rawgit.com/vkiryukhin/vkBeautify/master/vkbeautify.js"></script>
(function(undefined) {
var svgInput = document.getElementById('svgInput');
var svgOutput = document.getElementById('svgOutput');
/*Sync editor and image*/
//http://stackoverflow.com/questions/2823733/textarea-onchange-detection
svgInput.addEventListener('input', refreshOutput);
refreshOutput();
function refreshOutput() {
//http://stackoverflow.com/questions/584751/inserting-html-into-a-div
svgOutput.innerHTML = svgInput.value;
}
/*Output coordinates when clicking the SVG*/
svgOutput.onclick = function(e) {
if(!$$1('#logCoords').checked) { return; }
var svgBounds = svgOutput.querySelector('svg')
.getBoundingClientRect(),
svgPosX = e.clientX - svgBounds.left,
svgPosY = e.clientY - svgBounds.top,
svgPos = [Math.round(svgPosX), Math.round(svgPosY)];
//console.log(svgPos);
editor.insert('<!-- ' + svgPos + '-->' );
}
/*Implement "Swiped arc"*/
$('#arcSwipe').click(function() {
function sq(x) { return x*x; }
function sqrt(x) { return Math.sqrt(x); }
var center = $('#arcCenter').val().split(',');
var centerX = parseFloat(center[0]), centerY = parseFloat(center[1]);
var start = $('#arcStart').val().split(',');
var startX = parseFloat(start[0]), startY = parseFloat(start[1]);
var degr = parseFloat($('#arcDegrees').val());
var big = (Math.abs(degr)>180) ? 1 : 0;
var ccw = (degr>0) ? 1 : 0;
var dx = startX-centerX;
var dy = startY-centerY;
var radStart = Math.atan(dy/dx);
if(dx<0) { radStart += Math.PI; }
var degrRad = degr * Math.PI/180;
var r = sqrt( sq(dx) + sq(dy) );
var radEnd = radStart + degrRad;
var endX = Math.cos(radEnd)*r + centerX;
var endY = Math.sin(radEnd)*r + centerY;
function coord(x, y, doRound) {
if(doRound) {
function round(num, digits) {
var log10 = Math.log10(Math.abs(num));
var multiplier = Math.pow(10, digits-Math.floor(log10)-1);
var normalized = Math.round(num*multiplier) / multiplier;
return normalized;
}
x = round(x, 4);
y = round(y, 4);
}
return x+','+y+' ' ;
}
var path = '<path d="M' + coord(centerX,centerY) +
'L' + coord(startX,startY) +
'A'+coord(r,r, true) + '0 ' + coord(big,ccw) + coord(endX,endY, true) + '" />\n';
//debugger
//$('#arcPath').text(path);
editor.insert(path);
editor.focus();
});
/*Implement "Split SVG segments"*/
$('#splitSegs').click(function() {
var cornerTol = Number($('#splitSegsCornerTolerance').val()) || 0;
var path = getCurrentTag(),
data = path.content.match(/ d="(.*?)"/)[1],
parsed = RosomanSVG.absolutize(RosomanSVG.parse(data)),
parts = [], currPart = [],
control, seg, prevSeg, addSeg, joinAngle,
svgGroup;
//vecPrevOut, vecIn, vecOut;
for(var i=1; i<parsed.length; i++) {
prevSeg = parsed[i-1];
seg = parsed[i];
addSeg = false;
switch(seg[0]) {
case 'S':
case 'T':
//Mirrors previous control point - always smooth:
addSeg = true;
if(seg.length === 3) {
control = {
x: seg.startPoint.x + (prevSeg.__vecOut[1].x - prevSeg.__vecOut[0].x),
y: seg.startPoint.y + (prevSeg.__vecOut[1].y - prevSeg.__vecOut[0].y),
};
}
else {
control = { x: seg[1], y: seg[2] };
}
seg.__vecOut = [control, seg.endPoint];
break;
case 'Z':
//Belongs to the current segment and must be added:
addSeg = true;
break;
case 'H':
case 'V':
case 'L':
//Straight lines:
seg.__vecIn = seg.__vecOut = [seg.startPoint, seg.endPoint];
break;
case 'C':
case 'Q':
//Bezier curves:
control = { x: seg[1], y: seg[2] };
seg.__vecIn = [seg.startPoint, control,];
control = { x: seg[seg.length-4], y: seg[seg.length-3] };
seg.__vecOut = [control, seg.endPoint];
break;
default:
//M: Explicitly start a new segment
//A: May calculate entry and exit direction at some point, but not now...
break;
}
//See if the current join is smooth enough to include this segment:
if(prevSeg.__vecOut && seg.__vecIn) {
joinAngle = Math.findAngle(prevSeg.__vecOut[0], prevSeg.__vecOut[1], seg.__vecIn[1]);
console.log(prevSeg, '->', seg, ':', joinAngle);
addSeg = ((Math.PI-joinAngle) <= cornerTol);
}
if(addSeg) {
currPart.push(seg);
}
else {
if(currPart.length) {
parts.push(currPart);
}
currPart = [ seg ];
if(seg[0] !== 'M') {
currPart.unshift(['M', seg.startPoint.x, seg.startPoint.y]);
}
}
}
if(currPart.length) {
parts.push(currPart);
}
if(parts.length) {
svgGroup =
'<g stroke-width="2" fill="none" >\n ' +
parts.map(function(part, i) {
var d = RosomanSVG.serialize(part),
c = ABOUtils.colorPalette(i);
return '<path d="' +d+ '" stroke="' +c+ '" />';
}).join('\n ') +
'\n</g>';
console.log(svgGroup);
editor.moveCursorToPosition(path.end);
editor.clearSelection();
editor.insert('\n' + svgGroup);
}
});
function getCurrentTag() {
var pos = editor.getCursorPosition(),
rowIndex = pos.row,
colIndex,
tempLine;
var startLine = editor.session.getLine(rowIndex),
tagStart = { row: rowIndex },
tagEnd = { row: rowIndex },
tag = startLine;
//<
colIndex = pos.column;
tempLine = startLine;
while((tagStart.column = tempLine.lastIndexOf('<', colIndex)) < 0) {
tempLine = editor.session.getLine(--tagStart.row);
colIndex = tempLine.length;
tag = tempLine + tag;
}
tag = tag.slice(tagStart.column);
//console.log('<:', tag);
//>
colIndex = pos.column;
tempLine = startLine;
while((tagEnd.column = tempLine.indexOf('>', colIndex)) < 0) {
tempLine = editor.session.getLine(++tagEnd.row);
colIndex = 0;
tag += tempLine;
}
tagEnd.column++;
tag = tag.slice(0, (tagEnd.column - tempLine.length) || undefined );
//console.log('>:', tag);
//editor.moveCursorToPosition(tagEnd);
//editor.clearSelection();
//editor.insert('\n');
return {
start: tagStart,
end: tagEnd,
content: tag
};
}
/*Drag and drop background image for tracing*/
ABOUtils.dropImage(svgOutput, function (data) {
svgOutput.style.backgroundImage = "url('" + data + "')";
});
/*Download the completed image*/
var downloader = document.getElementById('downloader');
downloader.addEventListener('click', download);
function download(e) {
var svg = svgInput.value;
//http://stackoverflow.com/questions/2483919/how-to-save-svg-canvas-to-local-filesystem
//Option 1 - opens image in new tab:
//e.preventDefault();
//open("data:image/svg+xml," + encodeURIComponent(svg));
//Option 2 - actual download:
var b64 = btoa(unescape(encodeURIComponent(svg)));
downloader.setAttribute('href', 'data:image/svg+xml;base64,' + b64);
}
//Export PNG:
(function(link, inputW, inputAA) {
var actuallyDownloading = false;
link.onclick = function(e) {
//console.log('Png clicked', actuallyDownloading)
if(actuallyDownloading) { return; }
e.preventDefault();
var img = new Image(),
svgElm = document.querySelector('svg', svgOutput),
w = svgElm.clientWidth,
h = svgElm.clientHeight;
//Width/anti-aliasing:
//http://stackoverflow.com/a/37897818/1869660
//https://jsfiddle.net/uqfgs477/1/
var overrideW = Number(inputW.value);
var antiAlias = inputAA.checked;
if(overrideW || !antiAlias) {
svgElm = $(svgElm).clone()[0];
if(overrideW) {
//Replace width/height with only a viewBox to support resizing:
if(!svgElm.hasAttribute('viewBox')) {
svgElm.setAttribute('viewBox', '0,0 '+[w,h]);
}
svgElm.removeAttribute('width');
svgElm.removeAttribute('height');
h = h * (overrideW/w);
w = overrideW;
}
if(!antiAlias) {
//This doesn't take care of text and clip-paths.
//See further filter-ing below.
svgElm.setAttribute('shape-rendering', 'crispEdges');
}
}
//http://stackoverflow.com/a/20559830/1869660
var svgCode = $('<div>').append($(svgElm).clone()).html();
if(!antiAlias) {
var svgContentPos = svgCode.indexOf( '<', svgCode.indexOf('<svg')+1 );
//http://stackoverflow.com/questions/35434315/how-to-get-crispedges-for-svg-text
//(and also clip-path...)
svgCode =
svgCode.substring(0, svgContentPos) +
'<defs>' +
'<filter id="crispify"><feComponentTransfer><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer></filter>' +
'</defs>' +
'<style>' +
"svg * { filter: url('#crispify'); }" +
'</style>' +
svgCode.substring(svgContentPos);
}
//If text can be edited (e.g. if the SVG is inside a <div contenteditable>),
//we may insert invalid SVG, such as and <br>. This must be removed, or else 'img' won't load:
svgCode = svgCode.replace(/ /g, ' ');
svgCode = svgCode.replace(/<br\W*?>/g, '');
//console.log(svgCode);
img.width = w;
img.height = h;
//https://stackoverflow.com/questions/28545619/javascript-which-parameters-are-there-for-the-onerror-event-with-image-objects
img.onerror = function(e) {
console.log('ERROR loading image', e);
}
img.onload = function() {
console.log('exporting - loaded');
var canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = w;
canvas.height = h;
ctx.drawImage(img, 0, 0);
/* Note:
For some colors, the "anti-anti-alias by filter" trick above
still leaves shades that are really close to the original along the edges.
When (!antiAlias), look into "Color quantization" on the canvas imageData?
- https://www.reddit.com/r/javascript/comments/1ruh9m/wrote_a_color_quantizer_try_it_out/
http://palebluepixel.org/static/projects/colorpal/
https://github.com/leeoniya/RgbQuant.js
- https://gist.github.com/nrabinowitz/1104622
- https://github.com/igor-bezkrovny/image-quantization
*/
//Fails for large ~1MB svgs...
// var pngData = canvas.toDataURL('image/png');
// link.setAttribute('href', pngData);
//
canvas.toBlob(function(blob) {
var newImg = document.createElement("img"),
url = URL.createObjectURL(blob);
link.href = url;
actuallyDownloading = true;
link.click();
actuallyDownloading = false;
});
};
//Fails for large ~1MB svgs...
// img.src = 'data:image/svg+xml;base64,' + btoa(svgCode);
//
var svgBlob = new Blob([svgCode], { type: 'image/svg+xml' }),
url = URL.createObjectURL(svgBlob);
//console.log('img', /*svgCode,*/ svgBlob, url)
img.src = url;
};
})($$1('#exporter-png'), $$1('#export-png-width'), $$1('#export-png-antialias'));
/*Add fancy-schmancy editor*/
//http://stackoverflow.com/questions/6440439/how-do-i-make-a-textarea-an-ace-editor
var editor = (function(textarea) {
var editID = textarea.attr('id') + '-proxy';
var editDiv = $('<div>', { id: editID, 'class': textarea.attr('class') })
.insertBefore(textarea);
textarea.hide();
var editor = ace.edit(editID);
editor.$blockScrolling = Infinity;
editor.setOptions({
//enableBasicAutocompletion: true,
enableLiveAutocompletion: true
});
var session = editor.getSession();
session.setMode("ace/mode/xml");
session.setValue(textarea.val());
session.on('change', function() {
textarea.val(session.getValue());
//This doesn't trigger the textarea's "input" event, so we need to push an update:
refreshOutput();
});
editDiv.resizable({
resize: function( event, ui ) { editor.resize(); },
//stop: function( event, ui ) { }
});
return editor;
})($(svgInput));
$('#prettify').click(function() {
var cur = editor.getCursorPosition();
var svg = editor.getSession().getValue();
svg = vkbeautify.xml(svg);
svg = svg.replace(/\s*xmlns([:|=])/g, ' xmlns$1');
editor.getSession().setValue(svg);
editor.moveCursorToPosition(cur);
editor.focus();
});
})();
<h1>Simple SVG Editor</h1>
<button id="prettify">Prettify</button>
<textarea id="svgInput" class="svg-editor" >
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="300" >
<!--http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands-->
<g stroke="black" stroke-width="2" fill="none" >
<path d="M90,90 v-80 a80,80 0 0,0 -80,80 z" fill="red" />
<path d="M100,100 h-80 a80,80 0 1,0 80,-80 z" fill="gold" />
</g>
<path d="M50,260 l 50,-20
a25,25 -30 0,1 50,-20 l 50,-20
a25,50 -30 0,1 50,-20 l 50,-20
a25,75 -30 0,1 50,-20 l 50,-20
a25,100 -30 0,1 50,-20 l 50,-20"
fill="none" stroke="blue" stroke-width="4" />
<path d="M386,258 q26,-100 53,-25 q26,75 53,0 q26,75 -80,50 c-80,-25 -53,-125 -26,-125 c53,-25 107,-25 107,50 q107,-50 53,50"
stroke-width="4" fill="none" stroke="limegreen"/>
</svg>
</textarea>
<div class="tool-row swipe-arc">
<b>Swiped Arc</b><br />
<div><label>Center:</label><input id="arcCenter" type="text" /></div>
<div><label>Starting point:</label><input id="arcStart" type="text" /></div>
<div><label>Degrees (CW):</label><input id="arcDegrees" type="text" /></div>
<div><button id="arcSwipe">Swipe</button></div>
<label id="arcPath" />
</div>
<div class="tool-row split-segs">
<b>Split curve segments</b><br />
<div><label>Corner tolerance (radians):</label><input id="splitSegsCornerTolerance" type="text" value=".5" /></div>
<div><button id="splitSegs">Split</button></div>
</div>
<div class="tool-row svg-preview" >
<label><input id="logCoords" type="checkbox" />Log click coordinates (inserted into SVG code)</label>
<div id="svgOutput" ></div>
</div>
<div class="tool-row downloaders" >
<a id="downloader" download="drawing.svg" href-lang="image/svg+xml" href="" >Download</a>
<div class="export-png">
<a id="exporter-png" download="drawing.png" href-lang="image/png" href="" >Export PNG</a>
<label>Width: <input id="export-png-width" /> px</label>
<label><input type="checkbox" id="export-png-antialias" checked />Anti-alias</label>
</div>
</div>