harunpehlivan
3/14/2018 - 10:58 PM

Simple SVG Editor

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;
        }
    }
}

Simple SVG Editor

Simple SVG code editor with live preview.

A Pen by HARUN PEHLİVAN on CodePen.

License.

<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 &nbsp; and <br>. This must be removed, or else 'img' won't load:
            svgCode = svgCode.replace(/&nbsp;/g, '&#160;');
            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>