Skip to content

Commit 7a57b74

Browse files
committed
first
0 parents  commit 7a57b74

5 files changed

Lines changed: 401 additions & 0 deletions

File tree

app.jsx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
function App () {
2+
const [state, dispatch] = React.useReducer(reducer, initState());
3+
4+
const svg = React.useRef(null);
5+
const [pt, setPT] = React.useState(undefined);
6+
React.useEffect(()=> { setPT(svg.current.createSVGPoint()) }, []);
7+
8+
const [viewbox, setViewbox] = React.useState({ Xmin:0, Ymin:0, width:300, height:300});
9+
const [crosshairs, setCrosshairs] = React.useState({x:0, y:0});
10+
const [rawSVG, setRawSVG] = React.useState(`<rect width='100%' height='100%' fill='#eee' />
11+
<g stroke='#333333'>
12+
<line x1='50%' x2='50%' y1='0' y2='100%' />
13+
<line x1='0' x2='100%' y1='50%' y2='50%' />
14+
</g> `);
15+
16+
const [mode, setMode] = React.useState(null);
17+
const [segments, setSegments] = React.useState([]);
18+
const [activePoint, setActivePoint] = React.useState(null);
19+
const [pointR, setPointR] = React.useState(3);
20+
const [stroke, setStroke] = React.useState('#333333');
21+
const [fill, setFill] = React.useState('none');
22+
const [strokewidth, setStrokewidth] = React.useState(1);
23+
const [opacity, setOpacity] = React.useState(1);
24+
const [linecap, setLinecap] = React.useState('round'); //butt, round or square
25+
const [linejoin, setLinejoin] = React.useState('round'); //arcs | bevel |miter | miter-clip | round
26+
27+
const Mode = ({mode}) => <button onClick={()=>setMode(mode)}>{mode}</button>;
28+
29+
const Point = ({p,indx,ptKey}) => {
30+
return (
31+
<circle cx={p.x} cy={p.y} r={pointR} stroke='red' strokeWidth={1} fill={ptKey.startsWith('cp') ? '#fff' : 'red'}
32+
onMouseDown={()=>setActivePoint(indx)}
33+
onContextMenu={e=> {
34+
e.preventDefault();
35+
// only remove last segment
36+
setActivePoint(null); // necessary?
37+
if (state.points[indx].segment === (state.segments.length-1))
38+
dispatch({mode:'eraseLastSegment', segment:state.points[indx].segment});
39+
}}
40+
/>
41+
)
42+
}
43+
44+
const Crosshairs = ({x,y}) => {
45+
return (
46+
<g id='crosshairs'>
47+
<line x1={viewbox.Xmin} x2={viewbox.width} y1={y} y2={y} />
48+
<line x1={x} x2={x} y1={viewbox.Ymin} y2={viewbox.height} />
49+
</g>
50+
)
51+
}
52+
53+
return (
54+
<div id='app'>
55+
56+
<svg ref={svg}
57+
viewBox={`${viewbox.Xmin} ${viewbox.Ymin} ${viewbox.width} ${viewbox.height}`}
58+
onMouseMove={e=>{
59+
if (pt !== undefined) {
60+
[pt.x, pt.y] = [e.clientX, e.clientY];
61+
let svgP = pt.matrixTransform(svg.current.getScreenCTM().inverse());
62+
let [x,y] = [~~svgP.x, ~~svgP.y];
63+
setCrosshairs({x:~~svgP.x, y:~~svgP.y});
64+
if (activePoint!==null)
65+
dispatch({mode:'changePoint', indx:activePoint, x:x, y:y});
66+
}
67+
}}
68+
69+
onMouseUp={()=>setActivePoint(null)}
70+
71+
onClick={()=>{dispatch({mode:mode, x:crosshairs.x, y:crosshairs.y})}}>
72+
73+
<g dangerouslySetInnerHTML={{__html: rawSVG}}></g>
74+
75+
<path stroke={stroke} fill={fill} strokeWidth={strokewidth} opacity={opacity} strokeLinecap={linecap} strokeLinejoin={linejoin} d={state.pathD} />
76+
77+
<Crosshairs viewbox={viewbox} x={crosshairs.x} y={crosshairs.y} />
78+
79+
{state.points.map( (p,indx) => <Point key={'point'+indx} p={p} indx={indx} ptKey={p.ptKey} /> )}
80+
81+
</svg>
82+
83+
84+
85+
86+
<div id='controlsDIV'>
87+
<ViewboxInputs viewbox={viewbox} setViewbox={setViewbox} />
88+
89+
<textarea onChange={e=> setRawSVG(e.target.value)} value={rawSVG} />
90+
91+
<fieldset id='modeButtons'>
92+
<legend>Path Commands</legend>
93+
<Mode mode='M' /> <Mode mode='H' /> <Mode mode='V' /> <Mode mode='L' />
94+
<Mode mode='Q' /> <Mode mode='C' />
95+
96+
{state.closed === '' ?
97+
<button onClick={()=>{dispatch({mode:'Close'})}}>Z</button> :
98+
<button style={{backgroundColor:'#ff0'}} onClick={()=>{dispatch({mode:'Open'})}}>Z</button>
99+
}
100+
101+
<button onClick={()=>{dispatch({mode:'Clear'})}}>Clear</button>
102+
</fieldset>
103+
104+
<fieldset id='pathAttributesFields'>
105+
<legend>Path Attributes</legend>
106+
107+
<div id='pathAttributesDIV'>
108+
109+
<div>
110+
<label htmlFor='widthSlider'>Width</label>
111+
<input id='widthSlider' type="range" value={strokewidth} onChange={e=>setStrokewidth(e.target.value)} />
112+
</div>
113+
114+
<div>
115+
<label htmlFor='opacitySlider'>Opacity</label>
116+
<input id='opacitySlider' type="range" min={0.0} max={1.0} step={.01} value={opacity} onChange={e=>setOpacity(e.target.value)} />
117+
</div>
118+
119+
<div>
120+
<label htmlFor='strokeColor'>Stroke Color</label>
121+
<input id='strokeColor' type="color" value={stroke} onChange={e=>setStroke(e.target.value)} />
122+
</div>
123+
124+
<div>
125+
<label htmlFor='fillColor'>Fill Color</label>
126+
<input id='fillColor' type="color" value={fill} onChange={e=>setFill(e.target.value)} />
127+
<span onClick={()=>setFill('none')}>X</span>
128+
</div>
129+
130+
<div>
131+
<label htmlFor='linecapMenu'>Linecap</label>
132+
<select name="linecapMenu" value={linecap} onChange={e=>setLinecap(e.target.value)}>
133+
<option value="butt">butt</option>
134+
<option value="round">round</option>
135+
<option value="square">square</option>
136+
</select>
137+
</div>
138+
139+
<div>
140+
<label htmlFor='linejoinMenu'>Linejoin</label>
141+
<select name="linejoinMenu" value={linejoin} onChange={e=>setLinejoin(e.target.value)}>
142+
<option value="arcs">arcs</option>
143+
<option value="bevel">bevel</option>
144+
<option value="miter">miter</option>
145+
<option value="miter-clip">miter-clip</option>
146+
<option value="round">round</option>
147+
</select>
148+
</div>
149+
150+
</div>
151+
</fieldset>
152+
153+
<div>
154+
<label htmlFor='pointSlider'>Point Radius</label>
155+
<input id='pointSlider' type="range" value={pointR} onChange={e=>setPointR(e.target.value)} />
156+
</div>
157+
158+
<pre>{`<path stroke="${stroke}" stroke-width="${strokewidth}" fill="${fill}" opacity="${opacity}" stroke-linecap="${linecap}" stroke-linejoin="${linejoin}" d="${state.pathD}" />`}</pre>
159+
160+
</div>
161+
162+
</div>
163+
)
164+
}
165+
166+
ReactDOM.render(<App />, main);

index.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="initial-scale=1, width=device-width">
7+
<title>Draw SVG Path</title>
8+
<script src="https://unpkg.com/babel-standalone/babel.min.js"></script>
9+
<script src="https://unpkg.com/react@latest/umd/react.development.js"></script>
10+
<script src="https://unpkg.com/react-dom@latest/umd/react-dom.development.js"></script>
11+
<link rel="stylesheet" type="text/css" href="style.css">
12+
</head>
13+
14+
<body>
15+
<main id="main"></main>
16+
<script src="reducer.js"></script>
17+
<script type="text/babel" src="viewbox.jsx"></script>
18+
<script type="text/babel" src="app.jsx"></script>
19+
<footer style="text-align:center; margin-top:3em;"><a href="https://sean.brunnock.com">Sean Brunnock</a></footer>
20+
</body>
21+
22+
</html>

reducer.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
const initState = () => { return { path:[], segments:[], points:[], pathD:'', closed:''} };
2+
3+
function reducer (state, action) {
4+
// path consists of segments
5+
6+
switch (action.mode) {
7+
8+
case 'Clear':
9+
return initState();
10+
11+
case 'Open':
12+
state.closed='';
13+
break;
14+
15+
case 'Close':
16+
state.closed=' Z';
17+
break;
18+
19+
case 'M':
20+
state.segments.push({command:'M', sp:{x:action.x, y:action.y}});
21+
state.path.push(`M${action.x},${action.y}`);
22+
state.points.push({segment:(state.segments.length-1), ptKey:'sp', x:action.x, y:action.y});
23+
break;
24+
25+
case 'H':
26+
state.path.push(`H${action.x}`);
27+
state.segments.push({command:'H', ep:{x:action.x, y:action.y}});
28+
state.points.push({segment:(state.segments.length-1), ptKey:'ep', x:action.x, y:state.points[state.points.length-1].y});
29+
break;
30+
31+
case 'V':
32+
state.path.push(`V${action.y}`);
33+
state.segments.push({command:'V', ep:{x:action.x, y:action.y}});
34+
state.points.push({segment:(state.segments.length-1), ptKey:'ep', y:action.y, x:state.points[state.points.length-1].x});
35+
break;
36+
37+
case 'L':
38+
state.path.push(`L${action.x},${action.y}`);
39+
state.segments.push({command:'L', ep:{x:action.x, y:action.y}});
40+
state.points.push({segment:(state.segments.length-1), ptKey:'ep', x:action.x, y:action.y});
41+
break;
42+
43+
case 'Q':
44+
let cp={};
45+
cp.x = ~~((action.x + state.points[state.points.length-1].x)/2);
46+
cp.y = ~~((action.y + state.points[state.points.length-1].y)/2);
47+
state.path.push(`Q ${cp.x},${cp.y} ${action.x},${action.y}`);
48+
state.segments.push({command:'Q', cp:{x:cp.x, y:cp.y}, ep:{x:action.x, y:action.y}});
49+
state.points.push({segment:(state.segments.length-1), ptKey:'cp', x:cp.x, y:cp.y});
50+
state.points.push({segment:(state.segments.length-1), ptKey:'ep', x:action.x, y:action.y});
51+
break;
52+
53+
case 'C':
54+
let cp1={}, cp2={};
55+
cp1.x = ~~((action.x + state.points[state.points.length-1].x)/2.05);
56+
cp1.y = ~~((action.y + state.points[state.points.length-1].y)/2.05);
57+
cp2.x = ~~((action.x + state.points[state.points.length-1].x)/1.95);
58+
cp2.y = ~~((action.y + state.points[state.points.length-1].y)/1.95);
59+
state.path.push(`C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${action.x},${action.y}`);
60+
state.segments.push({command:'C',
61+
cp1:{x:cp1.x, y:cp1.y},
62+
cp2:{x:cp2.x, y:cp2.y},
63+
ep:{x:action.x, y:action.y}});
64+
state.points.push({segment:(state.segments.length-1), ptKey:'cp1', x:cp1.x, y:cp1.y});
65+
state.points.push({segment:(state.segments.length-1), ptKey:'cp2', x:cp2.x, y:cp2.y});
66+
state.points.push({segment:(state.segments.length-1), ptKey:'ep', x:action.x, y:action.y});
67+
break;
68+
69+
case 'changePoint':
70+
// move segment
71+
let segIndx = state.points[action.indx].segment;
72+
let ptKey = state.points[action.indx].ptKey;
73+
state.segments[segIndx][ptKey].x = action.x;
74+
state.segments[segIndx][ptKey].y = action.y;
75+
let segment = state.segments[segIndx];
76+
let command = state.segments[segIndx].command;
77+
switch (command) {
78+
case 'M':
79+
state.path[segIndx] = `M${action.x},${action.y}`;
80+
break;
81+
case 'H':
82+
state.path[segIndx] = `H${action.x}`;
83+
break;
84+
case 'V':
85+
state.path[segIndx] = `V${action.y}`;
86+
break;
87+
case 'L':
88+
state.path[segIndx] = `L${action.x},${action.y}`;
89+
break;
90+
case 'Q':
91+
state.path[segIndx] = `Q ${segment.cp.x},${segment.cp.y} ${segment.ep.x},${segment.ep.y}`;
92+
break;
93+
case 'C':
94+
state.path[segIndx] = `C ${segment.cp1.x},${segment.cp1.y} ${segment.cp2.x},${segment.cp2.y} ${segment.ep.x},${segment.ep.y}`;
95+
break;
96+
}
97+
if (command !== 'V') state.points[action.indx].x = action.x;
98+
if (command !== 'H') state.points[action.indx].y = action.y;
99+
100+
// if next point is for a different segment
101+
// and if that segment is H or V, update its coords
102+
if ((typeof state.points[action.indx+1] !== 'undefined') &&
103+
(state.points[action.indx].segment !== state.points[action.indx+1].segment)) {
104+
if (state.segments[segIndx+1].command === 'H') state.points[action.indx+1].y = action.y;
105+
if (state.segments[segIndx+1].command === 'V') state.points[action.indx+1].x = action.x;
106+
}
107+
break;
108+
109+
case 'eraseLastSegment':
110+
// delete all points attached to segment
111+
state.points = state.points.filter(x => x.segment !== action.segment);
112+
state.segments.pop();
113+
state.path.pop();
114+
break;
115+
}
116+
117+
state.pathD = state.path.join(' ') + state.closed;
118+
return ({...state});
119+
}

style.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
body {
2+
padding:1%;
3+
}
4+
5+
button, input, textarea {
6+
font-size: 1em;
7+
}
8+
9+
fieldset {
10+
}
11+
12+
#app {
13+
display:flex;
14+
justify-content:center;
15+
}
16+
17+
#controlsDIV {
18+
display:flex;
19+
flex-direction:column;
20+
flex:1;
21+
}
22+
23+
#controlsDIV>* {
24+
margin:1ex;
25+
}
26+
27+
#viewboxDIV {
28+
display: flex;
29+
flex-wrap: wrap;
30+
}
31+
32+
#viewboxDIV div {
33+
margin: 0 1ex;
34+
}
35+
36+
#viewboxDIV input {
37+
width:4em;
38+
}
39+
40+
svg {
41+
border: 1px solid #444;
42+
height: 80vh;
43+
flex: 2;
44+
}
45+
46+
#crosshairs {
47+
stroke:#888;
48+
stroke-width:1;
49+
stroke-dasharray:2;
50+
}
51+
52+
textarea {
53+
height:6em;
54+
width:25em;
55+
}
56+
57+
#pathAttributesDIV {
58+
display:flex;
59+
flex-wrap:wrap;
60+
}
61+
#pathAttributesDIV>* {
62+
margin:1ex;
63+
flex-shrink:1;
64+
}
65+
66+
pre {
67+
border: 1px solid #000;
68+
padding:1ex;
69+
white-space: pre-wrap;
70+
max-width:40em;
71+
}

0 commit comments

Comments
 (0)