A Pen by Andreas Borgen on CodePen.
Created
February 23, 2022 21:17
-
-
Save Sphinxxxx/d70c99fbdfd5df16df489f6a584df377 to your computer and use it in GitHub Desktop.
SVG path editor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script>console.clear();</script> | |
<script src="https://unpkg.com/vue@2"></script> | |
<script src="https://unpkg.com/[email protected]/dist/vueDraggableNumber.umd.min.js"></script> | |
<script src="https://unpkg.com/drag-tracker@1"></script> | |
<script src="https://unpkg.com/@sphinxxxx/[email protected]"></script> | |
<script src="https://unpkg.com/d-path-parser@1"></script> | |
<main id="app"> | |
<header> | |
<label class="toggle-button"> | |
<input type="checkbox" v-model="view.showTools" /> | |
<span> | |
<svg stroke="black" fill="none" width="15" height="15"><path d="M10,0 v20" stroke-width="20" stroke-dasharray="3"/></svg> | |
</span> | |
</label> | |
<div id="quick-tools" v-visible="!view.showTools"> | |
<button aria-label="Insert segment" @click="addSeg"> | |
<svg fill="lime" stroke="black" width="19" height="19" xmlns="http://www.w3.org/2000/svg"><path d="M.5,6.5 h6v-6h6v6h6v6h-6v6h-6v-6h-6z"/></svg> | |
</button> | |
<button aria-label="Delete segment" @click="delSeg" :disabled="!canDelSeg"> | |
<svg fill="red" stroke="black" width="19" height="19" xmlns="http://www.w3.org/2000/svg"><path d="M.5,4.5 l4-4 5,5 5-5 4,4 -5,5 5,5 -4,4 -5-5 -5,5 -4-4 5-5z"/></svg> | |
</button> | |
</div> | |
<h2>SVG path editor</h2> | |
</header> | |
<div id="drawing" ref="drawing"> | |
<svg width="100%" height="100%"> | |
<defs> | |
<pattern id="pattern-squares-sm" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse"> | |
<path d="M0,11 V0 h11" _stroke="paleturquoise" stroke="#fea" /> | |
</pattern> | |
<pattern id="pattern-squares" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse"> | |
<rect width="100" height="100" stroke="none" fill="url(#pattern-squares-sm)"/> | |
<path d="M0,101 V0 h101" _stroke="lightskyblue" stroke="gold" /> | |
</pattern> | |
</defs> | |
<!-- Graph paper background --> | |
<g class="bg" stroke="none"> | |
<rect width="200%" height="200%" fill="url(#pattern-squares)"/> | |
<rect width="200%" height="200%" fill="url(#pattern-squares)" transform="scale(-1 1)" opacity=".5"/> | |
<rect width="200%" height="200%" fill="url(#pattern-squares)" transform="scale( 1 -1)" opacity=".5"/> | |
<rect width="200%" height="200%" fill="url(#pattern-squares)" transform="scale(-1 -1)" opacity=".5"/> | |
</g> | |
<g class="rendered"> | |
<path v-for="path in svg.paths" :d="renderPath(path)" /> | |
<!-- A roundabout way of marking the selected segment, | |
but this ensures everything is rendered correctly when the selected segment builds on the previous one (e.g. T/S commands) --> | |
<g v-if="selectedSeg"> | |
<path ref="selected-seg" class="selected" :d="selectedPath.firstSegments(selectedSeg, true).map(renderSeg)" /> | |
<!-- Only for measuring `stroke-dashoffset` on "selected-seg" above (see `watch: {}`) --> | |
<path ref="selected-seg-mask-measure" stroke="none" :d="selectedSegmentMask" /> | |
</g> | |
</g> | |
<g class="controls" v-for="path in svg.paths"> | |
<!-- Can't listen for events on the <segment> tag, need an actual element.. --> | |
<g v-for="seg in path.segments" :key="seg._key" @dragging="doSelect(path, seg)"> | |
<segment :seg="seg" :class="{ selected: selectedSeg === seg }"></segment> | |
</g> | |
</g> | |
<!-- Doesn't quite work together with zoomableSvg.. | |
<drag-node class="resizer" v-model="svg.size" _r="20"></drag-node> | |
--> | |
</svg> | |
</div> | |
<div id="tools" v-if="view.showTools"> | |
<fieldset id="seg-tools" class="tools" v-if="selectedSeg"> | |
<legend>Segment</legend> | |
<fieldset id="command" class="tool-row" :disabled="!selectedSeg.prevSeg"> | |
<ul> | |
<li v-for="commGroup in [/*'M',*/ 'LHV', 'CS', 'QT']"> | |
<label v-for="c in commGroup" class="toggle-button" :accesskey="c"> | |
<input type="radio" v-model="selectedSeg.command" :value="c" name="seg-comm"/> | |
<span>{{ c }}</span> | |
</label> | |
</li> | |
</ul> | |
</fieldset> | |
<div id="coords" class="tool-row"> | |
<!-- <fieldset> is the only container element that supports `disabled` --> | |
<fieldset :disabled="!selectedSeg.usesC1()"> | |
<legend>C1</legend> | |
<draggable-number-input v-model="selectedSeg.c1[0]" label="x"></draggable-number-input> | |
<draggable-number-input v-model="selectedSeg.c1[1]" label="y"> </draggable-number-input> | |
</fieldset> | |
<fieldset :disabled="!selectedSeg.usesC2()"> | |
<legend>C2</legend> | |
<draggable-number-input v-model="selectedSeg.c2[0]" label="x"></draggable-number-input> | |
<draggable-number-input v-model="selectedSeg.c2[1]" label="y"></draggable-number-input> | |
</fieldset> | |
<fieldset> | |
<legend>End</legend> | |
<draggable-number-input v-model="selectedSeg.end[0]" label="x"></draggable-number-input> | |
<draggable-number-input v-model="selectedSeg.end[1]" label="y"></draggable-number-input> | |
</fieldset> | |
</div> | |
</fieldset> | |
<fieldset id="path-tools" class="tools"> | |
<legend>Path</legend> | |
<button class="tool-row" @click="addSeg">Insert segment</button> | |
<button class="tool-row" @click="delSeg" :disabled="!canDelSeg">Delete segment</button> | |
<draggable-number-input class="tool-row" v-model.number="precision" :min="0" label="Precision"></draggable-number-input> | |
</fieldset> | |
<!--pre> | |
{{ selectedPath.segments.map(seg => [seg._key, renderSeg(seg)]).join('\n') }} | |
</pre--> | |
</div> | |
<code id="output">{{ editorOutput }}</code> | |
<textarea id="source" v-model="source" rows="5" @input="onSourceChange"></textarea> | |
</main> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//TODO | |
// - viewBox (ajustable rectangle) | |
// - Download SVG | |
// - CTRL + click = Add segment | |
(function() { | |
"use strict"; | |
class Path { | |
constructor(x, y) { | |
this.segments = [new Segment('M', [x || 0, y || 0])]; | |
} | |
addSegment(newSeg, afterThisOne) { | |
const segs = this.segments; | |
let index = -1; | |
if(afterThisOne) { | |
index = segs.indexOf(afterThisOne) + 1; | |
} | |
if(index < 0) { | |
afterThisOne = this.lastSegment(); | |
index = segs.length; | |
} | |
if(!newSeg) { | |
newSeg = new Segment(); | |
const [x, y] = afterThisOne.end; | |
newSeg.c1 = [x + 40, y - 30]; | |
newSeg.c2 = [x + 40, y + 30]; | |
newSeg.end = [x + 100, y]; | |
} | |
newSeg.prevSeg = afterThisOne; | |
segs.splice(index, 0, newSeg); | |
const nextSeg = segs[index + 1]; | |
if(nextSeg) { | |
nextSeg.prevSeg = newSeg; | |
} | |
return newSeg; | |
} | |
deleteSegment(seg) { | |
const segs = this.segments, | |
index = segs.indexOf(seg); | |
//Don't delete the starting "M": | |
if(index > 0) { | |
segs.splice(index, 1); | |
const nextSeg = segs[index]; | |
if(nextSeg) { | |
nextSeg.prevSeg = segs[index - 1]; | |
} | |
return true; | |
} | |
} | |
firstSegments(targetSegment, inclusive) { | |
let i = this.segments.indexOf(targetSegment); | |
if(i < 0) { return []; } | |
if(inclusive) { i++; } | |
return this.segments.slice(0, i); | |
} | |
lastSegment() { | |
return this.segments[this.segments.length - 1]; | |
} | |
render(precision) { | |
return this.segments.map(s => s.render(precision)).join(' '); | |
} | |
} | |
class Segment { | |
static #COUNTER = 0; | |
constructor(command, end) { | |
this._key = Segment.#COUNTER++; | |
this.command = command || 'C'; | |
this.c1 = [100, 10]; | |
this.c2 = [100, 190]; | |
this.end = end || [200, 100]; | |
this.prevSeg = null; | |
} | |
usesC1() { | |
return 'CQ'.includes(this.command); | |
} | |
usesC2() { | |
return 'CS'.includes(this.command); | |
} | |
render(precision) { | |
function printNum(num) { | |
let str = num.toFixed(precision); | |
//Remove trailing 0s: | |
if(precision > 0) { | |
str = str.replace(/\.?0+$/, ''); | |
} | |
return str; | |
} | |
let numbers; | |
switch(this.command) { | |
case 'H': | |
numbers = [this.end[0]]; | |
break; | |
case 'V': | |
numbers = [this.end[1]]; | |
break; | |
case 'C': | |
numbers = [this.c1, this.c2, this.end]; | |
break; | |
case 'Q': | |
numbers = [this.c1, this.end]; | |
break; | |
//Continued cubic - only controls its endpoint: | |
case 'S': | |
numbers = [this.c2, this.end]; | |
break; | |
case 'Z': | |
numbers = []; | |
break; | |
//M/L | |
//T (continued quad - doesn't control its control point) | |
//A (TODO: Usable editor UI?) | |
default: | |
numbers = [this.arcBlob || [], this.end]; | |
break; | |
} | |
if(precision || (precision === 0)) { | |
numbers = numbers.flat().map(printNum); | |
} | |
return this.command + (numbers || ''); | |
} | |
} | |
//Global state model. Can be changed from within Vue or from the outside. | |
const _svgState = { | |
size: [10, 401], | |
paths: [], | |
}; | |
function parsePath(d) { | |
if(d) { d = d.trim(); } | |
if(!d) { return null; } | |
//console.log(dPathParse('M46,162 C46,72,183,277,169,114 Q258,56,341,247')); | |
let segs; | |
try { | |
//M46,162 C46,72,183,277,169,114 C258,56,241,337,341,247 | |
segs = dPathParse(d); | |
} | |
catch(err) { | |
console.warn(`Error parsing "${d}": ${err} ${err.stack}`); | |
return null; | |
} | |
let x = 0, y = 0; | |
const first = segs[0]; | |
if((first.code === 'M') || (first.code === 'm')) { | |
({ x, y } = first.end); | |
segs = segs.slice(1); | |
} | |
const path = new Path(x, y); | |
let lastMove = [x, y]; | |
for(const seg of segs) { | |
const cmd = seg.code.toUpperCase(); | |
if(cmd === 'Z') { | |
//Will just add an extra unusable endpoint in the UI. | |
//Maybe add a `.closed` property to the previous segment? | |
[x, y] = lastMove; | |
continue; | |
} | |
function toAbsArray(obj) { | |
if(!obj) { return null; } | |
let xx = obj.x, | |
yy = obj.y; | |
if(seg.relative) { | |
xx += x; | |
yy += y; | |
} | |
return [xx, yy]; | |
} | |
let end; | |
if(seg.end) { | |
end = toAbsArray(seg.end); | |
} else { | |
const val = seg.value; | |
switch(cmd) { | |
case 'H': end = [seg.relative ? x + val : val, y]; break; | |
case 'V': end = [x, seg.relative ? y + val : val]; break; | |
//'Z' | |
default: end = [x, y]; | |
} | |
} | |
const newSeg = new Segment(cmd, end); | |
newSeg.c1 = toAbsArray(seg.cp1 || seg.cp) || [x, y]; | |
newSeg.c2 = toAbsArray(seg.cp2 || seg.cp) || end; | |
if(cmd === 'A') { | |
newSeg.arcBlob = [ | |
seg.radii.x, seg.radii.y, | |
seg.rotation, | |
[seg.large, seg.clockwise].map(b => (b ? 1 : 0)), | |
].flat(); | |
} | |
//console.log(JSON.stringify(newSeg), end); | |
path.addSegment(newSeg); | |
[x, y] = end; | |
if(cmd === 'M') { | |
lastMove = end; | |
} | |
} | |
return path; | |
/* | |
const path = new Path(46, 162); | |
let seg = path.addSegment(); | |
seg.c1 = [46, 72]; | |
seg.c2 = [183, 277]; | |
seg.end = [169, 114]; | |
seg = path.addSegment(); | |
seg.c1 = [258, 56]; | |
seg.c2 = [241, 337]; | |
seg.end = [341, 247]; | |
*/ | |
} | |
_svgState.paths.push(parsePath('M46,162 C46,72,183,277,169,114 Q258,56,341,247')); | |
Vue.component('drag-node', { | |
template: '<circle data-draggable @dragging="onDragging" :cx="absCoord[0]" :cy="absCoord[1]" :r="r" />', | |
props: { | |
coord: Array, | |
//If 'coord' is relative to some other point: | |
offsetCenter: Array, | |
r: { | |
default: 16, | |
} | |
}, | |
model: { | |
prop: 'coord', | |
event: 'do_it', | |
}, | |
computed: { | |
absCoord() { | |
const point = this.coord, | |
center = this.offsetCenter, | |
absCoord = center ? [ point[0] + center[0], point[1] + center[1] ] | |
: point; | |
return absCoord; | |
}, | |
}, | |
methods: { | |
onDragging(e) { | |
const point = e.detail.pos, | |
center = this.offsetCenter, | |
relCoord = center ? [ point[0] - center[0], point[1] - center[1] ] | |
: point; | |
this.$emit('do_it', relCoord); | |
}, | |
}, | |
}); | |
Vue.component('connector', { | |
template: '<line class="connector" :x1="start[0]" :y1="start[1]" :x2="absEnd[0]" :y2="absEnd[1]" />', | |
props: ['start', 'end', 'endIsRel'], | |
computed: { | |
absEnd() { | |
const start = this.start, | |
end = this.end, | |
absEnd = this.endIsRel ? [ start[0] + end[0], start[1] + end[1] ] | |
: end; | |
return absEnd; | |
} | |
} | |
}); | |
Vue.component('segment', { | |
template: | |
`<g class="segment"> | |
<!--path class="rendered" :d="'M' + segStart + ' ' + seg.render()" /--> | |
<g class="c1" v-if="seg.usesC1()"> | |
<connector :start="segStart" :end="seg.c1"></connector> | |
<drag-node class="seg-control" v-model="seg.c1"></drag-node> | |
</g> | |
<g class="c2" v-if="seg.usesC2()"> | |
<connector :start="seg.c2" :end="seg.end"></connector> | |
<drag-node class="seg-control" v-model="seg.c2"></drag-node> | |
</g> | |
<connector v-if="seg.command === 'Q'" :start="seg.c1" :end="seg.end"></connector> | |
<drag-node class="seg-end" v-model="seg.end"></drag-node> | |
</g>`, | |
props: { | |
seg: Object, | |
}, | |
watch: { | |
segStart(newCoord, oldCoord) { | |
this.seg.c1 = this.moveCoord(this.seg.c1, oldCoord, newCoord); | |
}, | |
'seg.end': function(newCoord, oldCoord) { | |
this.seg.c2 = this.moveCoord(this.seg.c2, oldCoord, newCoord); | |
}, | |
}, | |
computed: { | |
segStart() { | |
const prev = this.seg.prevSeg; | |
return prev ? prev.end : [0, 0]; | |
}, | |
}, | |
methods: { | |
moveCoord(coord, baseOld, baseNew) { | |
const dX = baseNew[0] - baseOld[0], | |
dY = baseNew[1] - baseOld[1]; | |
return [coord[0] + dX, coord[1] + dY]; | |
}, | |
}, | |
}); | |
//https://stackoverflow.com/a/55859183/1869660 | |
Vue.directive('visible', (el, bind) => { | |
el.style.visibility = (!!bind.value) ? 'visible' : 'hidden'; | |
}); | |
new Vue({ | |
el: '#app', | |
components: { | |
'draggable-number-input': vueDraggableNumber, | |
}, | |
data: { | |
source: '', | |
svg: _svgState, | |
selectedPath: null, | |
selectedSeg: null, | |
precision: 1, | |
view: { | |
showTools: false, | |
} | |
}, | |
computed: { | |
selectedSegmentMask() { | |
const seg = this.selectedSeg; | |
if(!seg) { return ''; } | |
return this.selectedPath.firstSegments(seg).map(this.renderSeg); | |
}, | |
canDelSeg() { | |
return (this.selectedSeg && this.selectedSeg.prevSeg); | |
}, | |
editorOutput() { | |
//const code = this.selectedPath ? this.renderPath(this.selectedPath) : ''; | |
const code = this.svg.paths.map(this.renderPath).join('\n\n'); | |
this.source = code; | |
return code; | |
} | |
}, | |
watch: { | |
selectedSegmentMask(now, before) { | |
//console.log('SPM', now); | |
if(!this.selectedSeg) { return; } | |
Vue.nextTick(() => { | |
const selectionElm = this.$refs['selected-seg'], | |
totalLen = selectionElm.getTotalLength(), | |
maskLen = this.$refs['selected-seg-mask-measure'].getTotalLength(); | |
selectionElm.setAttribute('stroke-dasharray', totalLen * 10); | |
selectionElm.setAttribute('stroke-dashoffset', -maskLen); | |
}); | |
}, | |
//source(now, before) { | |
// console.log('S-W', now, before); | |
//} | |
}, | |
mounted() { | |
console.log('MTD'); | |
const that = this, | |
svg = document.querySelector('#drawing svg'); | |
function onDrag(node, pos) { | |
//Transform to SVG coordinates: | |
//https://www.sitepoint.com/how-to-translate-from-dom-to-svg-coordinates-and-back-again/ | |
const svgBounds = svg.getBoundingClientRect(), | |
screenCoord = svg.createSVGPoint(); | |
screenCoord.x = pos[0] + svgBounds.left; | |
screenCoord.y = pos[1] + svgBounds.top; | |
const svgCoord = screenCoord.matrixTransform(svg.getScreenCTM().inverse()), | |
pos2 = [svgCoord.x, svgCoord.y].map(a => Number(a.toFixed(that.precision))); | |
//Raise our custon "dragging" event: | |
var event = document.createEvent('CustomEvent'); | |
event.initCustomEvent('dragging', true, false, { pos: pos2 } ); | |
//var event = new CustomEvent('dragging', { detail: { pos } }); | |
node.dispatchEvent(event); | |
} | |
dragTracker({ | |
container: svg, | |
selector: '[data-draggable]', | |
//The .resizer needs to be dragged outside.. | |
// dragOutside: false, | |
callback: onDrag , | |
//Raise the same event as when dragging, just to select segments on click: | |
callbackClick: onDrag, | |
}); | |
const path = this.svg.paths[0]; | |
if(path) { | |
this.doSelect(path, path.lastSegment()); | |
} | |
this.view.showTools = window.innerWidth > window.innerHeight; | |
Vue.nextTick(() => { | |
this._zoomer = zoomableSvg(svg, { | |
container: document.querySelector('#drawing'), | |
onChanged: this.onZoomed, | |
}); | |
this.onZoomed(); | |
}); | |
}, | |
methods: { | |
//addPath: addPath, | |
addSeg() { | |
const path = this.selectedPath || this.svg.paths[0]; | |
this.doSelect(path, path.addSegment(null, this.selectedSeg)); | |
}, | |
delSeg() { | |
const path = this.selectedPath, | |
seg = this.selectedSeg; | |
if(path && path.deleteSegment(seg)) { | |
this.doSelect(path, seg.prevSeg); | |
} | |
}, | |
doSelect(path, seg) { | |
this.selectedPath = path; | |
this.selectedSeg = seg; | |
}, | |
renderSeg(seg) { | |
return seg.render(this.precision); | |
}, | |
renderPath(path) { | |
return path.render(this.precision); | |
}, | |
onSourceChange(e) { | |
const s = this.source; | |
console.log('SOURCE', s); | |
let pathDatas; | |
//SVG with (potentially) multiple paths. Extract all `d="..."` attributes: | |
if(s.includes('<')) { | |
pathDatas = Array.from(s.matchAll(/\bd\s*=\s*["']([^"']*)/g)).map(m => m[1]); | |
} | |
//Single path data: | |
else { | |
pathDatas = [s]; | |
} | |
pathDatas = pathDatas.filter(d => d && d.trim()); | |
//No data: Add a starting point for manual inserts. | |
if(pathDatas.length === 0) { | |
pathDatas.push('M0,0'); | |
} | |
const paths = pathDatas.flatMap(d => { | |
try { | |
const path = parsePath(d); | |
if(path) return [path]; | |
} | |
catch(err) { | |
console.warn(err); | |
} | |
return []; | |
}); | |
//Invalid data: Common during source editing, do nothing. | |
if(paths.length === 0) { | |
return; | |
} | |
const iPath = this.selectedPath && this.svg.paths.indexOf(this.selectedPath), | |
iSeg = this.selectedSeg && this.selectedPath.segments.indexOf(this.selectedSeg); | |
//console.log('paths', paths, iPath, iSeg); | |
this.svg.paths = paths; | |
//Restore selection: | |
const newPath = paths[iPath] || paths[paths.length - 1], | |
newSeg = newPath.segments[iSeg] || newPath.lastSegment(); | |
//console.log('newsel', newPath, newSeg); | |
this.doSelect(newPath, newSeg); | |
/* | |
const newPath = parsePath(this.source); | |
if(newPath && this.selectedPath) { | |
this.selectedPath.segments = newPath.segments; | |
} | |
*/ | |
}, | |
onZoomed() { | |
const zoom = this._zoomer && this._zoomer.getZoom(); | |
if(zoom) { | |
//console.log('zoom', zoom); | |
this.$refs['drawing'].style.setProperty('--screen-px', 1/zoom); | |
} | |
} | |
}, | |
filters: { | |
prettyCompact: function(obj) { | |
if(!obj) return ''; | |
const pretty = JSON.stringify(obj, null, 2), | |
//Collapse simple arrays (arrays without objects or nested arrays) to one line: | |
compact = pretty.replace(/\[[^[{]*?]/g, (match => match.replace(/\s+/g, ' '))) | |
return compact; | |
} | |
}, | |
}); | |
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
html, body { | |
height: 100%; | |
} | |
body { | |
display: flex; | |
flex-flow: column nowrap; | |
margin: 0; | |
padding: .5em; | |
box-sizing: border-box; | |
font-family: Georgia, sans-serif; | |
h2 { | |
font-size: 1.1em; | |
text-align: center; | |
margin: 0; | |
} | |
ul, fieldset { | |
list-style: none; | |
border: none; | |
margin: 0; | |
padding: 0; | |
} | |
button { | |
font: inherit; | |
} | |
label.toggle-button { | |
position: relative; | |
cursor: pointer; | |
input { | |
//https://zellwk.com/blog/hide-content-accessibly/ | |
position: absolute; | |
clip-path: circle(0); | |
} | |
span { | |
display: inline-block; | |
padding: .2em .5em; | |
background: whitesmoke; | |
box-shadow: 0 0 0 1px gainsboro; | |
} | |
input:checked + span { | |
background: dodgerblue; | |
color: white; | |
} | |
input:focus + span { | |
outline: 2px solid blue; | |
} | |
input:disabled + span { | |
opacity: .5; | |
pointer-events: none; | |
} | |
+ label.toggle-button { | |
margin-left: 3px; | |
} | |
} | |
} | |
#app { | |
flex: 1 1 auto; | |
display: grid; | |
gap: 1em .5em; | |
grid-template-areas: "top top" | |
"tool svg" | |
"out out"; | |
grid-template-columns: auto 1fr; | |
grid-template-rows: auto 1fr auto; | |
//background: lightskyblue; | |
input[type="number"] { | |
width: 8ch; | |
text-align: right; | |
} | |
header { | |
grid-area: top; | |
display: flex; | |
align-items: center; | |
.toggle-button span { | |
display: flex; | |
background: white !important; | |
box-shadow: none; | |
padding: 4px; | |
} | |
#quick-tools { | |
display: flex; | |
align-items: center; | |
margin-left: 1ch; | |
gap: .5ch; | |
button { | |
padding: 2px; | |
&:disabled svg { | |
fill-opacity: .2; | |
} | |
} | |
svg { display: block; } | |
} | |
h2 { | |
flex: 1 1 auto; | |
} | |
} | |
#drawing { | |
--screen-px: 1; | |
grid-area: svg; | |
position: relative; | |
} | |
#drawing svg { | |
position: absolute; | |
top:0; left:0; bottom:0; right:0; | |
fill: none; | |
.rendered, .connector { | |
pointer-events: none; | |
} | |
.rendered { | |
stroke: #444; | |
path { | |
//Messes with stroke-dasharray/offset on `selected-seg`: | |
//https://github.com/w3c/svgwg/issues/323 | |
// | |
// vector-effect: non-scaling-stroke; | |
stroke-width: var(--screen-px); | |
} | |
.selected { | |
stroke: black; | |
stroke-width: calc(var(--screen-px) * 2); | |
} | |
} | |
.controls .segment { | |
stroke: silver; | |
stroke-width: 1; | |
line, circle { | |
vector-effect: non-scaling-stroke; | |
} | |
.connector { | |
stroke-dasharray: 2; | |
} | |
.seg-control, .seg-end { | |
r: calc(var(--screen-px) * 16); | |
stroke-dasharray: 6 4; | |
fill: transparent; | |
cursor: pointer; | |
} | |
.seg-control { | |
stroke: lightskyblue; | |
} | |
&.selected { | |
stroke: black; | |
stroke-width: 2; | |
.connector { | |
stroke-width: 1; | |
} | |
.seg-control { | |
stroke: dodgerblue; | |
} | |
} | |
} | |
} | |
#tools { | |
grid-area: tool; | |
} | |
fieldset.tools { | |
display: flex; | |
flex-flow: column nowrap; | |
align-items: flex-end; | |
> legend { | |
font-weight: bold; | |
} | |
+ fieldset.tools { | |
margin-top: 1em; | |
} | |
} | |
.tool-row { | |
margin-top: .5em; | |
} | |
#seg-tools { | |
#command { | |
ul { | |
display: flex; | |
} | |
li { | |
display: flex; | |
font-family: monospace; | |
+ li { | |
margin-left: 1ch; | |
} | |
} | |
} | |
#coords { | |
fieldset { | |
display: flex; | |
justify-content: flex-end; | |
align-items: center; | |
} | |
legend { | |
//https://stackoverflow.com/a/24627851/1869660 | |
float: left; | |
margin-right: .5ch; | |
} | |
.vue-draggable-number-container label { | |
font-size: 0; | |
} | |
} | |
} | |
#output { | |
grid-area: out; | |
margin: 0; | |
padding: .5em 1em; | |
height: 4em; | |
overflow: auto; | |
background: lightyellow; | |
border: 1px solid gold; | |
} | |
#source { | |
grid-area: out; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment