Playing around with WYSIWYG editing of an SVG Beziér curve..
A Pen by Andreas Borgen on CodePen.
| //- Challenge (nested transforms): https://a.hrc.onl/img/mark-h.svg | |
| //- | |
| //- Clean (zoom out to see controls...): | |
| //- <svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" | |
| //- viewBox="0 0 336 288" height="288" width="336" > | |
| //- <g transform="matrix(1.3333333,0,0,-1.3333333,0,288)" > | |
| //- <g transform="scale(0.1)" style="fill:#254da3;fill-opacity:1;fill-rule:nonzero;stroke:none" > | |
| //- <path d="M 720,0 0,0 l 0,720 90,360 -90,360 0,720 720,0 0,-2160" /> | |
| //- <path d="m 2160,0 -720,0 90,1080 -90,1080 720,0 0,-2160" /> | |
| //- <path d="m 2520,1080 -1080,1080 0,-720 -1440,0 0,-720 1440,0 0,-720 1080,1080" style="fill:#e51b2d" /> | |
| //- </g> | |
| //- </g> | |
| //- </svg> | |
| //- | |
| //- Possible tools to flatten transforms: | |
| //- https://github.com/adobe-webplatform/Snap.svg/issues/199 | |
| //- https://gist.github.com/timo22345/9413158 | |
| //- http://jsfiddle.net/Nv78L/35/ | |
| //- http://stackoverflow.com/questions/5149301/baking-transforms-into-svg-path-element-commands | |
| //- https://github.com/svg/svgo | |
| //- https://github.com/svg/svgo/issues/344 | |
| //- https://github.com/RazrFalcon/SVGCleaner | |
| //- http://stackoverflow.com/a/15113751/1869660 | |
| //- | |
| #input | |
| h2 SVG or path data: | |
| textarea(rows=2) M90 150 c10 143 80-73 146 140 l-27-170-70 166 c113-205 121 31 171-115 | |
| //- M83.042,5.1183 h50v50 c-17.494-0.0693-32.193,12.866-40.115,27.573-15.016,28.085-14.637,64.08,1.47,91.619,8.2224,13.896,22.852,25.561,39.678,25.065,17.327-0.61091,31.829-13.477,39.421-28.33,13.688-25.755,13.954-58.007,1.7246-84.359-7.21-15.386-20.89-29.537-38.524-31.39-1.214-0.1219-2.434-0.1833-3.654-0.1823z | |
| input#show-original(type='checkbox', checked) | |
| label(for='show-original') Show original | |
| input#show-controls(type='checkbox', checked) | |
| label(for='show-controls') Show controls | |
| #drawing-board | |
| #original.layer | |
| #drawing.layer | |
| #controls.layer | |
| svg(xmlns='http://www.w3.org/2000/svg') | |
| #output | |
| h2 Edited | |
| //pre | |
| textarea(disabled, rows=5) |
| window.onerror = function(msg, url, line) { alert('Error: '+msg+'\nURL: '+url+'\nLine: '+line); }; | |
| ABOUtils.log2screen(); | |
| "use strict"; | |
| function Coord(x, y) { | |
| this.x = x; | |
| this.y = y; | |
| } | |
| Coord.prototype.negate = function() { | |
| return new Coord(-this.x, -this.y); | |
| } | |
| Coord.prototype.subtract = function(c2) { | |
| return new Coord(this.x - c2.x, this.y - c2.y); | |
| }; | |
| Coord.prototype.toArray = function() { | |
| return [this.x, this.y]; | |
| }; | |
| function zoomableSvg(svg, options) { | |
| if(typeof(svg) === 'string') { svg = document.querySelector(svg); } | |
| options = options || {}; | |
| let _ui = options.container || svg, | |
| _dragOffset, | |
| _pinchState, | |
| _zoom, | |
| _viewport = { | |
| width: _ui.clientWidth, | |
| height: _ui.clientHeight | |
| }, | |
| _viewBox = (function parseVB(vbAttr) { | |
| const vb = vbAttr && vbAttr.split(/[ ,]/) | |
| .filter(x => x.length) | |
| .map(x => Number(x)); | |
| if(vb && (vb.length === 4)) { | |
| return { | |
| left: vb[0], | |
| top: vb[1], | |
| width: vb[2], | |
| height: vb[3] | |
| }; | |
| } | |
| })(svg.getAttribute('viewBox')); | |
| const _public = { | |
| getViewBox: getViewBox, | |
| getZoom: function() { return _zoom; }, | |
| vp2vb: vp2vb | |
| }; | |
| if(_viewBox) { | |
| //Adjust the zoom in case the SVG has been resized via CSS: | |
| _zoom = _viewport.width/_viewBox.width; | |
| //If the SVG is inside a container, adjust the SVG's viewBox to the container's aspect ratio, | |
| //or else vp2vb() calculations won't be accurate: | |
| if(options.container) { | |
| changeZoom(0, new Coord(0,0)); | |
| } | |
| } | |
| else { | |
| _zoom = 1; | |
| _viewBox = { | |
| left: 0, | |
| top: 0, | |
| width: _viewport.width, | |
| height: _viewport.height | |
| }; | |
| updateViewBox(); | |
| } | |
| //Zoom | |
| _ui.addEventListener('wheel', function(e) { | |
| e.preventDefault(); | |
| changeZoom((e.deltaY > 0) ? -.1 : .1, ABOUtils.relativeMousePos(e, _ui)); | |
| }); | |
| _ui.addEventListener('touchmove', function(te) { | |
| var touches = te.touches; //touchEvent.targetTouches; | |
| if(touches.length !== 2) { | |
| _pinchState = null; | |
| return; | |
| } | |
| te.preventDefault(); | |
| //console.log(touches[0].identifier, touches[1].identifier); | |
| const p1 = ABOUtils.relativeMousePos(touches[0], _ui), | |
| p2 = ABOUtils.relativeMousePos(touches[1], _ui), | |
| dx = p1.x - p2.x, | |
| dy = p1.y - p2.y, | |
| pinch = { | |
| center: new Coord((p1.x + p2.x)/2, (p1.y + p2.y)/2), | |
| dist: Math.sqrt(dx*dx + dy*dy), | |
| }; | |
| if(_pinchState) { | |
| moveViewport(pinch.center.subtract(_pinchState.center)); | |
| changeZoom((pinch.dist/_pinchState.dist) - 1, pinch.center); | |
| } | |
| _pinchState = pinch; | |
| }); | |
| _ui.addEventListener('touchend', function (te) { | |
| _pinchState = null; | |
| }); | |
| //Drag | |
| dragTracker({ | |
| container: _ui, | |
| //selector: ..., | |
| callbackDragStart: (_, pos) => { | |
| _dragOffset = pos; | |
| }, | |
| callback: (_, pos, start) => { | |
| moveViewport(new Coord(pos[0] - _dragOffset[0], pos[1] - _dragOffset[1])); | |
| _dragOffset = pos; | |
| }, | |
| callbackDragEnd: () => { | |
| _dragOffset = null; | |
| } | |
| }); | |
| function changeZoom(delta, viewportCenter) { | |
| //console.log(delta, center); | |
| _zoom *= 1 + delta; | |
| setZoom(_zoom, viewportCenter); | |
| } | |
| function setZoom(zoom, viewportCenter) { | |
| var newVBW = _viewport.width/zoom, | |
| newVBH = _viewport.height/zoom, | |
| newVBTopLeft; | |
| var resizeFactor = newVBW/_viewBox.width, | |
| newVPRect = { | |
| w: _viewport.width * resizeFactor, | |
| h: _viewport.height * resizeFactor, | |
| t: viewportCenter.y - (viewportCenter.y * resizeFactor), | |
| l: viewportCenter.x - (viewportCenter.x * resizeFactor), | |
| }; | |
| newVBTopLeft = vp2vb( new Coord(newVPRect.l, newVPRect.t) ); | |
| _viewBox.top = newVBTopLeft.y; | |
| _viewBox.left = newVBTopLeft.x; | |
| _viewBox.width = newVBW; | |
| _viewBox.height = newVBH; | |
| //console.log(zoom, newVPRect, _viewBox); | |
| updateViewBox(); | |
| } | |
| function moveViewport(viewportDelta) { | |
| var vbDelta = vp2vb(viewportDelta.negate()); | |
| _viewBox.top = vbDelta.y; | |
| _viewBox.left = vbDelta.x; | |
| //console.log(viewportDelta, vbDelta); | |
| updateViewBox(); | |
| } | |
| //Viewport coordinate -> viewBox coordinate: | |
| function vp2vb(vpCoord) { | |
| var relX = vpCoord.x/_viewport.width, | |
| relY = vpCoord.y/_viewport.height, | |
| vbX = _viewBox.width *relX + _viewBox.left, | |
| vbY = _viewBox.height*relY + _viewBox.top, | |
| vbCoord = new Coord(vbX, vbY); | |
| //console.log(_viewBox, [relX, relY], '->', vbCoord); | |
| return vbCoord; | |
| } | |
| function getViewBox() { | |
| return [_viewBox.left, _viewBox.top, _viewBox.width, _viewBox.height]; | |
| } | |
| function updateViewBox() { | |
| const viewBox = getViewBox(); | |
| svg.setAttribute('viewBox', viewBox); | |
| if(options.onChanged) { options.onChanged.call(_public); } | |
| } | |
| return _public; | |
| } | |
| (function(undefined) { | |
| const RAD_CONTROL = 16, | |
| RAD_END = 16, | |
| ZOOM_MAX = 1000; | |
| var _svg, | |
| _origSvg, | |
| _viewBox, | |
| _viewport, | |
| _zoomer, | |
| _input = $$1('#input textarea'), | |
| _output = $$1('#output textarea'), | |
| _board = $$1('#drawing-board'), | |
| _original = $$1('#original'), | |
| _drawing = $$1('#drawing'), | |
| _controls = $$1('#controls svg'); | |
| function PathKeeper(ui, segments) { | |
| this.ui = ui; | |
| this.segments = segments; | |
| } | |
| function UIDataBinding(keeper, segment, coordIndex) { | |
| this.keeper = keeper; | |
| this.segment = segment; | |
| this.coordIndex = coordIndex; | |
| } | |
| const svgUI = { | |
| createElement: function(parent, name, attributes) { | |
| //http://stackoverflow.com/questions/16488884/add-svg-element-to-existing-svg-using-dom | |
| var svgNS = 'http://www.w3.org/2000/svg'; | |
| var elm = document.createElementNS(svgNS, name); | |
| for(var attr in attributes) { | |
| elm.setAttributeNS(null, attr, attributes[attr]); | |
| } | |
| parent.appendChild(elm); | |
| return elm; | |
| }, | |
| getAttr: function(element, attr) { | |
| return element.getAttributeNS(null, attr); | |
| }, | |
| setAttr: function(element, attr, val) { | |
| if(val || (val === 0)) { | |
| element.setAttributeNS(null, attr, val); | |
| } | |
| else { | |
| element.removeAttributeNS(null, attr); | |
| } | |
| }, | |
| unwrapVal: function(prop) { | |
| //[SVGAnimatedLength]... | |
| var val = prop.baseVal.value; | |
| //console.log(prop, val); | |
| return val; | |
| }, | |
| }; | |
| function updatePath(keeper) { | |
| var pathUI = keeper.ui, | |
| segments = keeper.segments, | |
| data = RosomanSVG.serialize(segments); | |
| svgUI.setAttr(pathUI, 'd', data); | |
| } | |
| function updateHandles(pointUI) { | |
| var handles = pointUI.__handles; | |
| if(!handles) { return; } | |
| handles.forEach(function(h) { | |
| var p1 = h.__p1, | |
| p2 = h.__p2; | |
| //console.log(h, p1, p2); | |
| var x1 = svgUI.unwrapVal(p1.cx), | |
| y1 = svgUI.unwrapVal(p1.cy), | |
| x2 = svgUI.unwrapVal(p2.cx), | |
| y2 = svgUI.unwrapVal(p2.cy), | |
| data = ['M', [x1,y1], 'L', [x2,y2]].join(' '); | |
| svgUI.setAttr(h, 'd', data); | |
| }); | |
| } | |
| function movePoint(pointUI, viewportCoord) { | |
| var coord = _zoomer.vp2vb(viewportCoord); | |
| svgUI.setAttr(pointUI, 'cx', coord.x); | |
| svgUI.setAttr(pointUI, 'cy', coord.y); | |
| ////[_bezier, 'c2'] => _bezier['c2'] = ... | |
| //pointUI.dataBinding[0][pointUI.dataBinding[1]] = dragPos; | |
| var binding = pointUI.dataBinding; | |
| binding.segment[binding.coordIndex] = coord.x; | |
| binding.segment[binding.coordIndex+1] = coord.y; | |
| updatePath(binding.keeper); | |
| updateHandles(pointUI); | |
| } | |
| function init(svml) { | |
| _controls.innerHTML = ''; | |
| _drawing.innerHTML = svml; | |
| _svg = $$1('svg', _drawing); | |
| svgUI.setAttr(_svg, 'width', ''); | |
| svgUI.setAttr(_svg, 'height', ''); | |
| //http://stackoverflow.com/questions/12592417/outerhtml-of-an-svg-element | |
| _original.innerHTML = _drawing.innerHTML; //_svg.outerHTML; | |
| _origSvg = $$1('svg', _original); | |
| //__updateViewBox(); | |
| function onZoomed() { | |
| //Sync all SVGs: | |
| const zoomer = this, | |
| vbAttr = '' + zoomer.getViewBox(); | |
| svgUI.setAttr(_controls, 'viewBox', vbAttr); | |
| svgUI.setAttr(_svg, 'viewBox', vbAttr); | |
| svgUI.setAttr(_origSvg, 'viewBox', vbAttr); | |
| //Adjust the controls UI to stay the same size: | |
| let zoom = zoomer.getZoom(); | |
| _controls.style.fontSize = (1/zoom) + 'em'; | |
| $$('.bez-control').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_CONTROL/zoom); }); | |
| $$('.endpoint').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_END/zoom); }); | |
| outputSvg(); | |
| } | |
| _zoomer = zoomableSvg(_svg, { | |
| container: _board, | |
| onChanged: onZoomed | |
| }); | |
| //Create UI for Bezier end and control points: | |
| function createControlPoint(keeper, segment, index) { | |
| //console.log('control-point', segment); | |
| const c = svgUI.createElement(_controls, 'circle', { | |
| class: 'bez-control', | |
| 'data-draggable': '', | |
| cx: segment[index], | |
| cy: segment[index+1], | |
| r: RAD_CONTROL | |
| }); | |
| c.dataBinding = new UIDataBinding(keeper, segment, index); | |
| return c; | |
| } | |
| function createEndPoint(keeper, segment, index) { | |
| const c = svgUI.createElement(_controls, 'circle', { | |
| class: 'endpoint', | |
| 'data-draggable': '', | |
| cx: segment[index], | |
| cy: segment[index+1], | |
| r: RAD_END | |
| }); | |
| c.dataBinding = new UIDataBinding(keeper, segment, index); | |
| return c; | |
| } | |
| function createHandle(p1, p2) { | |
| const handle = svgUI.createElement(_controls, 'path', { class: 'bez-handle' }); | |
| function addHandle(p) { | |
| p.__handles = p.__handles || []; | |
| p.__handles.push(handle); | |
| } | |
| handle.__p1 = p1; | |
| handle.__p2 = p2; | |
| addHandle(p1, handle); | |
| addHandle(p2, handle); | |
| updateHandles(p1); | |
| } | |
| const paths = $$('path', _svg); | |
| paths.forEach(function(path) { | |
| const data = svgUI.getAttr(path, 'd'), | |
| segmentsRel = RosomanSVG.parse(data), | |
| segments = RosomanSVG.absolutize(segmentsRel), | |
| keeper = new PathKeeper(path, segments); | |
| let prevEndPoint, prevEndCoord; | |
| //console.log(data, segmentsRel, segments); | |
| //For simplify.js: | |
| //console.log('points', JSON.stringify(segments.map(s => s.endPoint))); | |
| segments.forEach(function(seg) { | |
| const segType = seg[0]; | |
| let c1, c2, end; | |
| //First, transform H/V to L so they get proper endpoint coordinates: | |
| switch(segType) { | |
| case 'H': | |
| seg[0] = 'L'; | |
| seg[2] = prevEndCoord.y; | |
| break; | |
| case 'V': | |
| seg[0] = 'L'; | |
| seg[2] = seg[1]; | |
| seg[1] = prevEndCoord.x; | |
| break; | |
| } | |
| switch(segType) { | |
| case 'C': | |
| c1 = createControlPoint(keeper, seg, 1); | |
| c2 = createControlPoint(keeper, seg, 3); | |
| end = createEndPoint(keeper, seg, 5); | |
| createHandle(c1, prevEndPoint); | |
| createHandle(c2, end); | |
| prevEndPoint = end; | |
| prevEndCoord = new Coord(seg[5], seg[6]); | |
| break; | |
| //Standalone quad, or continued cubic: | |
| case 'Q': | |
| case 'S': | |
| c1 = createControlPoint(keeper, seg, 1); | |
| end = createEndPoint(keeper, seg, 3); | |
| if(segType === 'Q') { | |
| createHandle(c1, prevEndPoint); | |
| } | |
| createHandle(c1, end); | |
| prevEndPoint = end; | |
| prevEndCoord = new Coord(seg[3], seg[4]); | |
| break; | |
| case 'Z': | |
| break; | |
| //"L"/"H"/"V" (see above) | |
| //"T" (continued quad - doesn't control its control point) | |
| //"A" (TODO: Usable editor UI?) | |
| default: | |
| prevEndPoint = createEndPoint(keeper, seg, seg.length-2); | |
| prevEndCoord = new Coord(seg[seg.length-2], seg[seg.length-1]); | |
| break; | |
| } | |
| }); | |
| }); | |
| } | |
| _input.onclick = function() { | |
| //If no current selection | |
| if(_input.selectionStart === _input.selectionEnd) { | |
| this.select(); | |
| } | |
| } | |
| _input.oninput = function() { | |
| var svg = _input.value.trim(); | |
| if(svg[0] !== '<') { | |
| svg = '<svg xmlns="http://www.w3.org/2000/svg" >\n' + | |
| ' <path d="' + svg + '" fill="none" stroke="black" stroke-width="2"></path>\n' + | |
| '</svg>'; | |
| } | |
| init(svg); | |
| }; | |
| _input.oninput(); | |
| function outputSvg() { | |
| _output.value/*textContent*/ = _drawing.innerHTML.trim(); | |
| } | |
| /*User interaction (drag & drop)*/ | |
| dragTracker({ | |
| container: _controls, | |
| selector: '[data-draggable]', | |
| callback: (box, pos, start) => { | |
| movePoint(box, new Coord(pos[0], pos[1])); | |
| outputSvg(); | |
| }, | |
| }); | |
| })(); |
| "use strict"; | |
| function Coord(x, y) { | |
| this.x = x; | |
| this.y = y; | |
| this.negate = function() { | |
| return new Coord(-this.x, -this.y); | |
| } | |
| } | |
| Coord.prototype.toArray = function() { | |
| return [this.x, this.y]; | |
| }; | |
| function zoomableSvg(svg, options) { | |
| if(typeof(svg) === 'string') { svg = document.querySelector(svg); } | |
| options = options || {}; | |
| let _ui = options.container || svg, | |
| _dragOffset, | |
| _zoom = 1, | |
| _viewport = { | |
| width: _ui.clientWidth, | |
| height: _ui.clientHeight | |
| }, | |
| _viewBox = (function parseVB(vbAttr) { | |
| const vb = vbAttr && vbAttr.split(/[ ,]/) | |
| .filter(x => x.length) | |
| .map(x => Number(x)); | |
| if(vb && (vb.length === 4)) { | |
| return { | |
| left: vb[0], | |
| top: vb[1], | |
| width: vb[2], | |
| height: vb[3] | |
| }; | |
| } | |
| })(svg.getAttribute('viewBox')); | |
| const _public = { | |
| getViewBox: getViewBox, | |
| getZoom: function() { return _zoom; }, | |
| vp2vb: vp2vb | |
| }; | |
| if(!_viewBox) { | |
| _viewBox = { | |
| left: 0, | |
| top: 0, | |
| width: _viewport.width, | |
| height: _viewport.height | |
| }; | |
| updateViewBox(); | |
| } | |
| _ui.addEventListener('wheel', function(e) { | |
| e.preventDefault(); | |
| changeZoom((e.deltaY > 0) ? -.1 : .1, ABOUtils.relativeMousePos(e, _ui)); | |
| }); | |
| _ui.addEventListener('mousedown', function(e) { | |
| if(e.target.getAttribute('data-draggable')) { | |
| //Don't interfere with other draggable elements: | |
| return; | |
| } | |
| e.preventDefault(); | |
| const area = this.getBoundingClientRect(); | |
| _dragOffset = new Coord(e.clientX - area.left, e.clientY - area.top); | |
| }); | |
| _ui.addEventListener('mousemove', function(e) { | |
| //'mouseup' while out of window: | |
| if(_dragOffset && (e.buttons !== undefined) && (e.buttons !== 1)) { | |
| _dragOffset = null; | |
| } | |
| if(_dragOffset) { | |
| e.preventDefault(); | |
| const dragPos = ABOUtils.relativeMousePos(e, _ui); | |
| moveViewport(new Coord(dragPos.x - _dragOffset.x, dragPos.y - _dragOffset.y)); | |
| _dragOffset = dragPos; | |
| } | |
| }); | |
| function changeZoom(delta, viewportCenter) { | |
| //console.log(delta, center); | |
| _zoom *= 1 + delta; | |
| setZoom(_zoom, viewportCenter); | |
| } | |
| function setZoom(zoom, viewportCenter) { | |
| var newVBW = _viewport.width/zoom, | |
| newVBH = _viewport.height/zoom, | |
| newVBTopLeft; | |
| var resizeFactor = newVBW/_viewBox.width, | |
| newVPRect = { | |
| w: _viewport.width * resizeFactor, | |
| h: _viewport.height * resizeFactor, | |
| t: viewportCenter.y - (viewportCenter.y * resizeFactor), | |
| l: viewportCenter.x - (viewportCenter.x * resizeFactor), | |
| }; | |
| newVBTopLeft = vp2vb( new Coord(newVPRect.l, newVPRect.t) ); | |
| _viewBox.top = newVBTopLeft.y; | |
| _viewBox.left = newVBTopLeft.x; | |
| _viewBox.width = newVBW; | |
| _viewBox.height = newVBH; | |
| //console.log(zoom, newVPRect, _viewBox); | |
| updateViewBox(); | |
| } | |
| function moveViewport(viewportDelta) { | |
| var vbDelta = vp2vb(viewportDelta.negate()); | |
| _viewBox.top = vbDelta.y; | |
| _viewBox.left = vbDelta.x; | |
| //console.log(viewportDelta, vbDelta); | |
| updateViewBox(); | |
| } | |
| //Viewport coordinate -> viewBox coordinate: | |
| function vp2vb(vpCoord) { | |
| var relX = vpCoord.x/_viewport.width, | |
| relY = vpCoord.y/_viewport.height, | |
| vbX = _viewBox.width *relX + _viewBox.left, | |
| vbY = _viewBox.height*relY + _viewBox.top, | |
| vbCoord = new Coord(vbX, vbY); | |
| //console.log(_viewBox, [relX, relY], '->', vbCoord); | |
| return vbCoord; | |
| } | |
| function getViewBox() { | |
| return [_viewBox.left, _viewBox.top, _viewBox.width, _viewBox.height]; | |
| } | |
| function updateViewBox() { | |
| const viewBox = getViewBox(); | |
| svg.setAttribute('viewBox', viewBox); | |
| if(options.onChanged) { options.onChanged.call(_public); } | |
| } | |
| return _public; | |
| } | |
| (function(undefined) { | |
| const RAD_CONTROL = 10, | |
| RAD_END = 10, | |
| ZOOM_MAX = 1000; | |
| var _svg, | |
| _origSvg, | |
| _viewBox, | |
| _viewport, | |
| //_zoom, | |
| _zoomer, | |
| _input = $$1('#input textarea'), | |
| _output = $$1('#output pre'), | |
| _board = $$1('#drawing-board'), | |
| _original = $$1('#original'), | |
| _drawing = $$1('#drawing'), | |
| _controls = $$1('#controls svg'); | |
| function PathKeeper(ui, segments) { | |
| this.ui = ui; | |
| this.segments = segments; | |
| } | |
| function UIDataBinding(keeper, segment, coordIndex) { | |
| this.keeper = keeper; | |
| this.segment = segment; | |
| this.coordIndex = coordIndex; | |
| } | |
| var svgUI = { | |
| createElement: function(parent, name, attributes) { | |
| //http://stackoverflow.com/questions/16488884/add-svg-element-to-existing-svg-using-dom | |
| var svgNS = 'http://www.w3.org/2000/svg'; | |
| var elm = document.createElementNS(svgNS, name); | |
| for(var attr in attributes) { | |
| elm.setAttributeNS(null, attr, attributes[attr]); | |
| } | |
| parent.appendChild(elm); | |
| return elm; | |
| }, | |
| getAttr: function(element, attr) { | |
| return element.getAttributeNS(null, attr); | |
| }, | |
| setAttr: function(element, attr, val) { | |
| if(val || (val === 0)) { | |
| element.setAttributeNS(null, attr, val); | |
| } | |
| else { | |
| element.removeAttributeNS(null, attr); | |
| } | |
| }, | |
| unwrapVal: function(prop) { | |
| //[SVGAnimatedLength]... | |
| var val = prop.baseVal.value; | |
| //console.log(prop, val); | |
| return val; | |
| }, | |
| }; | |
| /* | |
| //Viewport coordinate -> viewBox coordinate: | |
| function __vp2vb(vpCoord) { | |
| var relX = vpCoord.x/_viewport.width, | |
| relY = vpCoord.y/_viewport.height, | |
| vbX = _viewBox.width *relX + _viewBox.left, | |
| vbY = _viewBox.height*relY + _viewBox.top, | |
| vbCoord = new Coord(vbX, vbY); | |
| //console.log(_viewBox, [relX, relY], '->', vbCoord); | |
| return vbCoord; | |
| } | |
| function __updateViewBox() { | |
| var viewBoxAttr = '' + [_viewBox.left, _viewBox.top, _viewBox.width, _viewBox.height] | |
| svgUI.setAttr(_controls, 'viewBox', viewBoxAttr); | |
| svgUI.setAttr(_svg, 'viewBox', viewBoxAttr); | |
| svgUI.setAttr(_origSvg, 'viewBox', viewBoxAttr); | |
| } | |
| */ | |
| function updatePath(keeper) { | |
| var pathUI = keeper.ui, | |
| segments = keeper.segments, | |
| data = RosomanSVG.serialize(segments); | |
| svgUI.setAttr(pathUI, 'd', data); | |
| } | |
| function updateHandles(pointUI) { | |
| var handles = pointUI.__handles; | |
| if(!handles) { return; } | |
| handles.forEach(function(h) { | |
| var p1 = h.__p1, | |
| p2 = h.__p2; | |
| //console.log(h, p1, p2); | |
| var x1 = svgUI.unwrapVal(p1.cx), | |
| y1 = svgUI.unwrapVal(p1.cy), | |
| x2 = svgUI.unwrapVal(p2.cx), | |
| y2 = svgUI.unwrapVal(p2.cy), | |
| data = ['M', [x1,y1], 'L', [x2,y2]].join(' '); | |
| svgUI.setAttr(h, 'd', data); | |
| }); | |
| } | |
| function movePoint(pointUI, viewportCoord) { | |
| var coord = _zoomer.vp2vb(viewportCoord); | |
| svgUI.setAttr(pointUI, 'cx', coord.x); | |
| svgUI.setAttr(pointUI, 'cy', coord.y); | |
| ////[_bezier, 'c2'] => _bezier['c2'] = ... | |
| //pointUI.dataBinding[0][pointUI.dataBinding[1]] = dragPos; | |
| var binding = pointUI.dataBinding; | |
| binding.segment[binding.coordIndex] = coord.x; | |
| binding.segment[binding.coordIndex+1] = coord.y; | |
| updatePath(binding.keeper); | |
| updateHandles(pointUI); | |
| } | |
| /* | |
| function changeZoom(delta, viewportCenter) { | |
| //console.log(delta, center); | |
| _zoom *= 1 + delta; | |
| setZoom(_zoom, viewportCenter); | |
| } | |
| function setZoom(zoom, viewportCenter) { | |
| var newVBW = _viewport.width/zoom, | |
| newVBH = _viewport.height/zoom, | |
| newVBTopLeft; | |
| var resizeFactor = newVBW/_viewBox.width, | |
| newVPRect = { | |
| w: _viewport.width * resizeFactor, | |
| h: _viewport.height * resizeFactor, | |
| t: viewportCenter.y - (viewportCenter.y * resizeFactor), | |
| l: viewportCenter.x - (viewportCenter.x * resizeFactor), | |
| }; | |
| newVBTopLeft = __vp2vb( new Coord(newVPRect.l, newVPRect.t) ); | |
| _viewBox.top = newVBTopLeft.y; | |
| _viewBox.left = newVBTopLeft.x; | |
| _viewBox.width = newVBW; | |
| _viewBox.height = newVBH; | |
| //console.log(zoom, newVPRect, _viewBox); | |
| __updateViewBox(); | |
| //Adjust the controls UI to stay the same size: | |
| _controls.style.fontSize = (1/zoom) + 'em'; | |
| $$('.bez-control').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_CONTROL/zoom); }) | |
| $$('.endpoint').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_END/zoom); }) | |
| } | |
| function moveViewport(viewportDelta) { | |
| var vbDelta = __vp2vb(viewportDelta.negate()); | |
| _viewBox.top = vbDelta.y; | |
| _viewBox.left = vbDelta.x; | |
| //console.log(viewportDelta, vbDelta); | |
| __updateViewBox(); | |
| } | |
| */ | |
| function init(svml) { | |
| var paths; | |
| /* | |
| _zoom = 1; | |
| _viewport = { | |
| width: _board.clientWidth, | |
| height: _board.clientHeight | |
| }; | |
| _viewBox = { | |
| top: 0, | |
| left: 0, | |
| width: _viewport.width, | |
| height: _viewport.height | |
| }; | |
| */ | |
| _controls.innerHTML = ''; | |
| _drawing.innerHTML = svml; | |
| _svg = $$1('svg', _drawing); | |
| svgUI.setAttr(_svg, 'width', ''); | |
| svgUI.setAttr(_svg, 'height', ''); | |
| //http://stackoverflow.com/questions/12592417/outerhtml-of-an-svg-element | |
| _original.innerHTML = _drawing.innerHTML; //_svg.outerHTML; | |
| _origSvg = $$1('svg', _original); | |
| //__updateViewBox(); | |
| function onZoomed() { | |
| //Sync all SVGs: | |
| const zoomer = this, | |
| vbAttr = '' + zoomer.getViewBox(); | |
| svgUI.setAttr(_controls, 'viewBox', vbAttr); | |
| svgUI.setAttr(_svg, 'viewBox', vbAttr); | |
| svgUI.setAttr(_origSvg, 'viewBox', vbAttr); | |
| //Adjust the controls UI to stay the same size: | |
| let zoom = zoomer.getZoom(); | |
| _controls.style.fontSize = (1/zoom) + 'em'; | |
| $$('.bez-control').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_CONTROL/zoom); }); | |
| $$('.endpoint').forEach(function(x) { svgUI.setAttr(x, 'r', RAD_END/zoom); }); | |
| outputSvg(); | |
| } | |
| _zoomer = zoomableSvg(_origSvg, { | |
| container: _board, | |
| onChanged: onZoomed | |
| }); | |
| //Create UI for Bezier end and control points: | |
| function createControlPoint(keeper, segment, index) { | |
| //console.log('control-point', segment); | |
| var c = svgUI.createElement(_controls, 'circle', { | |
| class: 'bez-control', | |
| 'data-draggable': true, | |
| cx: segment[index], | |
| cy: segment[index+1], | |
| r: RAD_CONTROL | |
| }); | |
| c.dataBinding = new UIDataBinding(keeper, segment, index); | |
| return c; | |
| } | |
| function createEndPoint(keeper, segment, index) { | |
| var c = svgUI.createElement(_controls, 'circle', { | |
| class: 'endpoint', | |
| 'data-draggable': true, | |
| cx: segment[index], | |
| cy: segment[index+1], | |
| r: RAD_END | |
| }); | |
| c.dataBinding = new UIDataBinding(keeper, segment, index); | |
| return c; | |
| } | |
| function createHandle(p1, p2) { | |
| var handle = svgUI.createElement(_controls, 'path', { class: 'bez-handle' }); | |
| function addHandle(p) { | |
| p.__handles = p.__handles || []; | |
| p.__handles.push(handle); | |
| } | |
| handle.__p1 = p1; | |
| handle.__p2 = p2; | |
| addHandle(p1, handle); | |
| addHandle(p2, handle); | |
| updateHandles(p1); | |
| } | |
| paths = $$('path', _svg); | |
| paths.forEach(function(path) { | |
| var data = svgUI.getAttr(path, 'd'), | |
| segmentsRel = RosomanSVG.parse(data), | |
| segments = RosomanSVG.absolutize(segmentsRel), | |
| keeper = new PathKeeper(path, segments), | |
| prevEndPoint, prevEndCoord; | |
| //console.log(data, segmentsRel, segments); | |
| segments.forEach(function(seg) { | |
| var segType = seg[0], | |
| c1, c2, end; | |
| //First, transform H/V to L so they get proper endpoint coordinates: | |
| switch(segType) { | |
| case 'H': | |
| seg[0] = 'L'; | |
| seg[2] = prevEndCoord.y; | |
| break; | |
| case 'V': | |
| seg[0] = 'L'; | |
| seg[2] = seg[1]; | |
| seg[1] = prevEndCoord.x; | |
| break; | |
| } | |
| switch(segType) { | |
| case 'C': | |
| c1 = createControlPoint(keeper, seg, 1); | |
| c2 = createControlPoint(keeper, seg, 3); | |
| end = createEndPoint(keeper, seg, 5); | |
| createHandle(c1, prevEndPoint); | |
| createHandle(c2, end); | |
| prevEndPoint = end; | |
| prevEndCoord = new Coord(seg[5], seg[6]); | |
| break; | |
| //Standalone quad, or continued cubic: | |
| case 'Q': | |
| case 'S': | |
| c1 = createControlPoint(keeper, seg, 1); | |
| end = createEndPoint(keeper, seg, 3); | |
| if(segType === 'Q') { | |
| createHandle(c1, prevEndPoint); | |
| } | |
| createHandle(c1, end); | |
| prevEndPoint = end; | |
| prevEndCoord = new Coord(seg[3], seg[4]); | |
| break; | |
| case 'Z': | |
| break; | |
| //"T" (continued quad - doesn't control its control point) | |
| //"L" | |
| //"A" (TODO: Usable editor UI?) | |
| default: | |
| prevEndPoint = createEndPoint(keeper, seg, seg.length-2); | |
| prevEndCoord = new Coord(seg[seg.length-2], seg[seg.length-1]); | |
| break; | |
| } | |
| }); | |
| }); | |
| } | |
| _input.onclick = function() { | |
| //If no current selection | |
| if(_input.selectionStart === _input.selectionEnd) { | |
| this.select(); | |
| } | |
| } | |
| _input.oninput = function() { | |
| var svg = _input.value.trim(); | |
| if(svg[0] !== '<') { | |
| svg = '<svg xmlns="http://www.w3.org/2000/svg" >\n' + | |
| ' <path d="' + svg + '" fill="none" stroke="black" stroke-width="2"></path>\n' + | |
| '</svg>'; | |
| } | |
| init(svg); | |
| }; | |
| _input.oninput(); | |
| /*User interaction (drag & drop)*/ | |
| //http://stackoverflow.com/questions/18425089/simple-drag-and-drop-code | |
| var dragged, dragOffset; | |
| document.body.addEventListener('mouseup', function() { | |
| dragged = undefined; | |
| }); | |
| document.body.addEventListener('mousemove', function(e) { | |
| //'mouseup' while out of window: | |
| if(dragged && (e.buttons !== undefined) && (e.buttons !== 1)) { | |
| dragged = undefined; | |
| } | |
| var dragPos; | |
| if(dragged) { | |
| /* | |
| if(dragged === _board) { | |
| dragPos = getBoardCoord(e); | |
| moveViewport(new Coord(dragPos.x - dragOffset.x, dragPos.y - dragOffset.y)); | |
| dragOffset = dragPos; | |
| } | |
| else */{ | |
| dragPos = getBoardCoord(e, dragOffset, false); | |
| movePoint(dragged, dragPos); | |
| } | |
| outputSvg(); | |
| } | |
| }); | |
| //[p1, c1, c2, p2].forEach(function(c) { | |
| // c.addEventListener('mousedown', function(e) { | |
| ABOUtils.live('mousedown', '.endpoint, .bez-control', function(e) { | |
| e.preventDefault(); | |
| dragged = this; | |
| //console.log('mousedown', this); | |
| var mousePos = new Coord(e.clientX, e.clientY); | |
| var draggedPos = dragged.getBoundingClientRect(); | |
| var draggedCenter = new Coord(draggedPos.left + draggedPos.width/2, | |
| draggedPos.top + draggedPos.height/2); | |
| dragOffset = new Coord(mousePos.x-draggedCenter.x, mousePos.y-draggedCenter.y); | |
| }); | |
| //Zoom & move | |
| /* | |
| _board.addEventListener('wheel', function(e) { | |
| e.preventDefault(); | |
| changeZoom((e.deltaY > 0) ? -.1 : .1, getBoardCoord(e)); | |
| outputSvg(); | |
| }); | |
| _board.addEventListener('mousedown', function(e) { | |
| e.preventDefault(); | |
| dragged = this; | |
| var area = this.getBoundingClientRect(); | |
| dragOffset = new Coord(e.clientX - area.left, e.clientY - area.top); | |
| //console.log(dragOffset); | |
| }); | |
| */ | |
| function getBoardCoord(mouseEvent, offset, restrict) { | |
| /* | |
| function respectBounds(value, min,max) { | |
| return Math.max(min, Math.min(value, max)); | |
| } | |
| offset = offset || { x:0, y:0 }; | |
| //Buggy in Firefox... | |
| //Related? http://stackoverflow.com/questions/11334452/event-offsetx-in-firefox | |
| // var dragPos = coord(e.offsetX+dragOffset.x, e.offsetY+dragOffset.y); | |
| var svgBounds = _board.getBoundingClientRect(); | |
| var x = mouseEvent.clientX - svgBounds.left - offset.x, | |
| y = mouseEvent.clientY - svgBounds.top - offset.y; | |
| if(restrict) { | |
| x = respectBounds(x, 0, svgBounds.width); | |
| y = respectBounds(y, 0, svgBounds.height); | |
| } | |
| return new Coord(x, y); | |
| */ | |
| var pos = ABOUtils.relativeMousePos(mouseEvent, _board, restrict); | |
| if(offset) { | |
| pos.x -= offset.x; | |
| pos.y -= offset.y; | |
| } | |
| return new Coord(pos.x, pos.y); | |
| } | |
| function outputSvg() { | |
| _output.textContent = _drawing.innerHTML.trim(); | |
| } | |
| })(); |
| <script src="https://codepen.io/Sphinxxxx/pen/VejGLv"></script> | |
| <script src="https://cdn.rawgit.com/Sphinxxxx/drag-tracker/v0.3/src/drag-tracker.js"></script> |
| $colorBG: rgba(0,0,0, 0); | |
| $colorBGPattern: #cdf; | |
| html, body { margin: 0; padding: 0; } | |
| body { font-family: Georgia, sans-serif; } | |
| h2 { margin:0; text-align:center; } | |
| textarea { | |
| width: 100%; | |
| box-sizing: border-box; | |
| white-space: pre; | |
| } | |
| #input { | |
| background: lawngreen; | |
| } | |
| #drawing-board { | |
| position: relative; | |
| width: 601px; | |
| height: 501px; | |
| margin: 1em auto; | |
| //background: linear-gradient(to right, $colorBG 48%, $colorBGPattern 48%, $colorBGPattern 50%, $colorBG 50%, $colorBG 100%), | |
| // linear-gradient(to bottom, $colorBG 48%, $colorBGPattern 48%, $colorBGPattern 50%, $colorBG 50%, $colorBG 100%); | |
| background: linear-gradient(to right, $colorBGPattern 1px, $colorBG 1px), | |
| linear-gradient(to bottom, $colorBGPattern 1px, $colorBG 1px); | |
| background-size: 20px 20px; | |
| .layer { | |
| position: absolute; | |
| top:0; left:0; bottom:0; right:0; | |
| } | |
| #original { | |
| opacity: .3; | |
| } | |
| #controls { | |
| //Base for stroke-width of controls UI, which is 'em'-sized to make resizing easier. | |
| //(when zooming, the child SVG changes its font-size +-1em). | |
| font-size: 10px; | |
| $stroke: .2em; | |
| .endpoint { | |
| fill: transparent; | |
| stroke: blue; | |
| stroke-width: $stroke; | |
| } | |
| .endpoint, .bez-control { | |
| cursor: pointer; | |
| } | |
| .bez-control, .bez-handle { | |
| fill: lightgreen; | |
| stroke: green; | |
| stroke-width: $stroke; | |
| stroke-dasharray: $stroke * 2; | |
| } | |
| .bez-handle { | |
| stroke: gold; | |
| z-index: -1; | |
| pointer-events: none; | |
| } | |
| } | |
| } | |
| #show-original:not(:checked) ~ #drawing-board #original { | |
| display: none; | |
| } | |
| #show-controls:not(:checked) ~ #drawing-board #controls { | |
| display: none; | |
| } | |
| #output { | |
| background: deepskyblue; | |
| padding-bottom: 0.5em; | |
| //pre { | |
| // width: 100%; | |
| // min-height: 1em; | |
| // max-height: 50vh; | |
| // margin: 0; | |
| // background: white; | |
| // overflow: auto; | |
| //} | |
| textarea { | |
| background: white; | |
| } | |
| } |
Playing around with WYSIWYG editing of an SVG Beziér curve..
A Pen by Andreas Borgen on CodePen.