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.