Skip to content

Instantly share code, notes, and snippets.

@Sphinxxxx
Created February 23, 2022 21:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Sphinxxxx/d70c99fbdfd5df16df489f6a584df377 to your computer and use it in GitHub Desktop.
Save Sphinxxxx/d70c99fbdfd5df16df489f6a584df377 to your computer and use it in GitHub Desktop.
SVG path editor
<script>console.clear();</script>
<script src="https://unpkg.com/vue@2"></script>
<script src="https://unpkg.com/vue-draggable-number@1.0.1/dist/vueDraggableNumber.umd.min.js"></script>
<script src="https://unpkg.com/drag-tracker@1"></script>
<script src="https://unpkg.com/@sphinxxxx/zoomable-svg@0.2"></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>
//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;
}
},
});
})();
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