├── public ├── favicon.ico ├── og_image.png ├── Bitstream-Vera-Sans-Mono-400.woff └── Bitstream-Vera-Sans-Mono-400.woff2 ├── .prettierrc ├── pages ├── _app.js └── index.js ├── index.html ├── src ├── utils │ ├── withUuids.js │ ├── hooks │ │ └── useWindowSize.js │ ├── infoStringHelpers.js │ └── constants.js ├── main.jsx ├── styles │ ├── editor.module.scss │ ├── paste_box.module.scss │ ├── color_picker.module.scss │ ├── example_shapes.module.scss │ ├── viewer.module.scss │ ├── button_row.module.scss │ ├── home.module.scss │ └── command.module.scss ├── components │ ├── color_picker.jsx │ ├── learn_more.jsx │ ├── editor.jsx │ ├── example_shapes.jsx │ ├── head.jsx │ ├── paste_box.jsx │ ├── button_row.jsx │ ├── viewer.jsx │ └── command.jsx ├── index.css ├── App.jsx └── models │ └── command_model.js ├── vite.config.js ├── .gitignore ├── README.md ├── package.json ├── LICENSE ├── .github └── workflows │ └── deploy.yml └── yarn.lock /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rfauver/svg_path_editor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rfauver/svg_path_editor/HEAD/public/og_image.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /public/Bitstream-Vera-Sans-Mono-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rfauver/svg_path_editor/HEAD/public/Bitstream-Vera-Sans-Mono-400.woff -------------------------------------------------------------------------------- /public/Bitstream-Vera-Sans-Mono-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rfauver/svg_path_editor/HEAD/public/Bitstream-Vera-Sans-Mono-400.woff2 -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return 5 | } 6 | 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/utils/withUuids.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | export default function withUuids(strings) { 4 | return strings.map(str => ({ raw: str, uuid: uuidv4() })); 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.jsx' 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/styles/editor.module.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | font-family: var(--font-mono); 6 | font-size: 14px; 7 | line-height: 18px; 8 | min-width: 504px; 9 | 10 | @media (max-width: 1060px) { 11 | min-width: 0; 12 | } 13 | } 14 | 15 | .indented { 16 | margin-left: 20px; 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /out/ 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | pnpm-debug.log* 9 | lerna-debug.log* 10 | 11 | .env 12 | 13 | node_modules 14 | dist 15 | dist-ssr 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SVG Path Editor 2 | 3 | SVG Path Editor is an interactive tool to edit an SVG by editing the path commands that describe its shape. See it running at [svg-path.com](https://svg-path.com) 4 | 5 | image 6 | 7 | ## License 8 | [MIT](https://github.com/rfauver/svg_path_editor/blob/main/LICENSE) 9 | -------------------------------------------------------------------------------- /src/styles/paste_box.module.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | width: 537px; 3 | height: 68px; 4 | margin-right: auto; 5 | padding: 8px 12px; 6 | border-radius: 6px; 7 | border: none; 8 | outline: none; 9 | font-size: 14px; 10 | line-height: 18px; 11 | color: var(--text-color); 12 | font-family: var(--font-mono); 13 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); 14 | background-color: var(--panel-color); 15 | 16 | @media (max-width: 620px) { 17 | width: 100%; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/hooks/useWindowSize.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useWindowSize() { 4 | const [windowSize, setWindowSize] = useState({ 5 | width: 0, 6 | height: 0, 7 | }); 8 | 9 | const handleSize = () => { 10 | setWindowSize({ 11 | width: window.innerWidth, 12 | height: window.innerHeight, 13 | }); 14 | }; 15 | 16 | useEffect(() => { 17 | handleSize(); 18 | window.addEventListener('resize', handleSize); 19 | return () => window.removeEventListener('resize', handleSize); 20 | }, []); 21 | 22 | return windowSize; 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svg_path_editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "classnames": "^2.5.1", 13 | "react": "^19.2.0", 14 | "react-dom": "^19.2.0", 15 | "react-helmet": "^6.1.0", 16 | "sass": "^1.70.0", 17 | "uuid": "^9.0.1" 18 | }, 19 | "devDependencies": { 20 | "@vitejs/plugin-react": "^5.1.1", 21 | "globals": "^16.5.0", 22 | "prettier": "3.2.4", 23 | "vite": "^7.2.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/color_picker.module.scss: -------------------------------------------------------------------------------- 1 | .fillColor { 2 | color: var(--text-color); 3 | padding: 4px; 4 | border-radius: 4px; 5 | width: 67px; 6 | border: none; 7 | background: unset; 8 | outline: none; 9 | font-family: inherit; 10 | font-size: inherit; 11 | &:hover, 12 | &:focus { 13 | box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.08); 14 | background-color: var(--background-color); 15 | } 16 | } 17 | 18 | .colorPickerWrapper { 19 | border-radius: 50%; 20 | border: 1px solid rgba(0, 0, 0, 0.2); 21 | position: relative; 22 | bottom: 1px; 23 | } 24 | 25 | .colorPicker { 26 | opacity: 0; 27 | width: 17px; 28 | cursor: pointer; 29 | } 30 | -------------------------------------------------------------------------------- /src/styles/example_shapes.module.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | display: flex; 3 | width: 100%; 4 | margin: 14px 0 10px; 5 | 6 | @media (max-width: 620px) { 7 | overflow: auto; 8 | padding-bottom: 4px; 9 | } 10 | } 11 | 12 | .shape { 13 | min-width: 60px; 14 | height: 60px; 15 | background-color: var(--panel-color); 16 | border-radius: 10px; 17 | border: none; 18 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); 19 | padding: 10px; 20 | margin-right: 16px; 21 | cursor: pointer; 22 | 23 | &:hover { 24 | background-color: var(--button-hover-color); 25 | } 26 | 27 | &:active { 28 | transform: scale(0.95); 29 | box-shadow: 1px 1px 4px rgb(0 0 0 / 25%); 30 | } 31 | } 32 | 33 | .paste { 34 | color: var(--text-color); 35 | font-size: 16px; 36 | padding: 10px 20px; 37 | min-width: 80px; 38 | } 39 | -------------------------------------------------------------------------------- /src/styles/viewer.module.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | display: flex; 3 | margin: 14px 0 0 6px; 4 | 5 | @media (max-width: 620px) { 6 | margin: 6px 0 0 4px; 7 | } 8 | 9 | svg path { 10 | transition: d 300ms; 11 | } 12 | svg circle { 13 | transition: 14 | cx 300ms, 15 | cy 300ms; 16 | } 17 | } 18 | 19 | .yCoords { 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: space-between; 23 | text-align: right; 24 | margin: 20px 4px 1px 4px; 25 | } 26 | 27 | .rightWrapper { 28 | width: 100%; 29 | } 30 | 31 | .xCoords { 32 | display: flex; 33 | justify-content: space-between; 34 | margin-bottom: 2px; 35 | } 36 | 37 | .coord { 38 | @media (max-width: 620px) { 39 | font-size: 14px; 40 | } 41 | } 42 | 43 | .view { 44 | border-top: 1px dashed var(--axis-color); 45 | border-left: 1px dashed var(--axis-color); 46 | } 47 | -------------------------------------------------------------------------------- /src/styles/button_row.module.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | height: 60px; 3 | margin-top: auto; 4 | display: flex; 5 | gap: 10px; 6 | 7 | button { 8 | cursor: pointer; 9 | flex: 1; 10 | font-size: 16px; 11 | padding: 4px 6px; 12 | border-radius: 10px; 13 | border: none; 14 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); 15 | background-color: var(--panel-color); 16 | color: var(--text-color); 17 | svg { 18 | height: 16px; 19 | } 20 | &:hover { 21 | background-color: var(--button-hover-color); 22 | } 23 | &:active { 24 | transform: scale(0.95); 25 | } 26 | } 27 | 28 | .convert { 29 | min-width: 152px; 30 | } 31 | 32 | @media (max-width: 1060px) { 33 | height: 52px; 34 | 35 | button { 36 | font-size: 14px; 37 | } 38 | 39 | .convert { 40 | min-width: 136px; 41 | } 42 | } 43 | 44 | @media (max-width: 500px) { 45 | .convert { 46 | min-width: 0; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/color_picker.jsx: -------------------------------------------------------------------------------- 1 | import styles from '../styles/color_picker.module.scss'; 2 | 3 | export default function ColorPicker({ fillColor, setFillColor }) { 4 | const onFillColorChange = e => { 5 | let input = e.target.value.trimStart(); 6 | if (input[0] !== '#') { 7 | input = '#' + input; 8 | } 9 | setFillColor(input); 10 | }; 11 | const onColorPickerChange = e => { 12 | setFillColor(e.target.value); 13 | }; 14 | 15 | return ( 16 | <> 17 | 25 | 29 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ryan Fauver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/learn_more.jsx: -------------------------------------------------------------------------------- 1 | export default function LearnMore() { 2 | return ( 3 | <> 4 |

Learn More

5 |

6 | 10 | MDN Docs on the d attribute 11 | 12 |

13 |

14 | 18 | The SVG `path` Syntax: An Illustrated Guide from CSS-Tricks 19 | 20 |

21 |

22 | 23 | Demystifyingish SVG paths from HTTP 203 24 | 25 |

26 |

27 | 28 | SVG Path Visualizer 29 | 30 |

31 |

32 | 33 | Source code on Github 34 | 35 |

36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/editor.jsx: -------------------------------------------------------------------------------- 1 | import ColorPicker from '../components/color_picker'; 2 | import Command from '../components/command'; 3 | import { firstLine } from '../utils/constants'; 4 | 5 | import styles from '../styles/editor.module.scss'; 6 | 7 | export default function Editor({ 8 | commands, 9 | fillColor, 10 | setFillColor, 11 | setCursorPosition, 12 | updateInstructions, 13 | addCommand, 14 | removeCommand, 15 | setHoveredIndex, 16 | setActiveIndex, 17 | maxCoord, 18 | }) { 19 | return ( 20 |
21 |
{firstLine(maxCoord)}
22 |
23 | {''}
41 |
{''}
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/example_shapes.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PasteBox from '../components/paste_box'; 3 | import { SHAPES } from '../utils/constants'; 4 | import withUuids from '../utils/withUuids'; 5 | import classnames from 'classnames'; 6 | 7 | import styles from '../styles/example_shapes.module.scss'; 8 | 9 | export default function ExampleShapes({ fillColor, setInstructions }) { 10 | const [showPasteBox, setShowPasteBox] = useState(false); 11 | 12 | return ( 13 | <> 14 |
15 | {Object.entries(SHAPES).map(([name, shapeInstructions]) => { 16 | return ( 17 | 26 | ); 27 | })} 28 | 34 |
35 | {showPasteBox && } 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v5 33 | - name: Set up Node 34 | uses: actions/setup-node@v6 35 | with: 36 | node-version: lts/* 37 | cache: 'yarn' 38 | - name: Install dependencies 39 | run: yarn install --frozen-lockfile 40 | - name: Build 41 | run: yarn build 42 | env: 43 | VITE_FATHOM_DOMAIN: ${{ vars.VITE_FATHOM_DOMAIN }} 44 | VITE_FATHOM_ID: ${{ vars.VITE_FATHOM_ID }} 45 | - name: Setup Pages 46 | uses: actions/configure-pages@v5 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v4 49 | with: 50 | # Upload dist folder 51 | path: './dist' 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /src/components/head.jsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | 3 | export default function Head({ instructions, svgText }) { 4 | return ( 5 | <> 6 | 7 | SVG Path Editor 8 | 12 | 16 | 17 | 22 | 23 | 24 | 25 | {/* Fathom - simple website analytics */} 26 | {import.meta.env.PROD && ( 27 | 40 | )} 41 | {/* / Fathom */} 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-sans: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 3 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 4 | --font-mono: 'Bitstream Vera Sans Mono', monospace; 5 | --background-color: white; 6 | --panel-color: #f2f2f2; 7 | --input-color: var(--background-color); 8 | --text-color: #222; 9 | --link-color: #0070f3; 10 | --toggle-disabled-color: #ccc; 11 | --info-color: #777; 12 | --button-hover-color: #e2e3e5; 13 | --axis-color: #70769e; 14 | --input-shadow: 1px 1px 4px rgba(0, 0, 0, 0.08); 15 | --highlight-input-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); 16 | --annotation-shadow: 3px 3px 4px 1px rgba(0, 0, 0, 0.16); 17 | } 18 | 19 | @media (prefers-color-scheme: dark) { 20 | :root { 21 | --background-color: #2a303a; 22 | --panel-color: #37393e; 23 | --input-color: #252424; 24 | --text-color: #eee; 25 | --link-color: #5da4f6; 26 | --toggle-disabled-color: var(--input-color); 27 | --info-color: #ccc; 28 | --button-hover-color: #444851; 29 | --axis-color: #888; 30 | --input-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); 31 | --highlight-input-shadow: var(--input-shadow); 32 | --annotation-shadow: 4px 4px 8px 1px rgba(0, 0, 0, 0.4); 33 | } 34 | } 35 | 36 | html, 37 | body { 38 | padding: 0; 39 | margin: 0; 40 | font-family: var(--font-sans); 41 | background-color: var(--background-color); 42 | color: var(--text-color); 43 | } 44 | 45 | a { 46 | color: var(--link-color); 47 | text-decoration: none; 48 | } 49 | 50 | * { 51 | box-sizing: border-box; 52 | } 53 | 54 | @font-face { 55 | font-family: 'Bitstream Vera Sans Mono'; 56 | src: 57 | url('../Bitstream-Vera-Sans-Mono-400.woff2') format('woff2'), 58 | url('../Bitstream-Vera-Sans-Mono-400.woff') format('woff'); 59 | font-display: swap; 60 | } 61 | -------------------------------------------------------------------------------- /src/styles/home.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | max-width: 1500px; 4 | padding: 0 24px; 5 | margin: 0 auto; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | 10 | @media (max-width: 1060px) { 11 | max-width: 790px; 12 | padding: 0 14px; 13 | } 14 | } 15 | 16 | .heading { 17 | font-size: 90px; 18 | width: 100%; 19 | margin: 20px 0 0; 20 | 21 | @media (max-width: 1060px) { 22 | font-size: 60px; 23 | } 24 | } 25 | 26 | .intro { 27 | width: 100%; 28 | p { 29 | max-width: 625px; 30 | line-height: 22px; 31 | } 32 | } 33 | 34 | .code { 35 | font-family: 'Bitstream Vera Sans Mono', monospace; 36 | font-size: 14px; 37 | background: #e9e9e9; 38 | padding: 2px 6px; 39 | border-radius: 4px; 40 | } 41 | 42 | .main { 43 | display: grid; 44 | grid-template-columns: repeat(2, 1fr); 45 | column-gap: 30px; 46 | width: 100%; 47 | margin: 10px 0; 48 | @media (max-width: 1060px) { 49 | grid-template-columns: 1fr; 50 | } 51 | } 52 | 53 | .sectionWrapper { 54 | display: flex; 55 | flex-direction: column; 56 | gap: 10px; 57 | 58 | @media (max-width: 1060px) { 59 | max-width: 750px; 60 | margin-bottom: 10px; 61 | flex-direction: column-reverse; 62 | } 63 | } 64 | 65 | .section { 66 | height: 100%; 67 | background-color: var(--panel-color); 68 | border-radius: 20px; 69 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); 70 | padding: 20px 24px; 71 | @media (max-width: 1060px) { 72 | max-width: 750px; 73 | } 74 | @media (max-width: 620px) { 75 | padding: 14px; 76 | } 77 | } 78 | 79 | .viewer { 80 | position: sticky; 81 | top: 24px; 82 | height: fit-content; 83 | padding: 0 24px 20px 0; 84 | @media (max-width: 620px) { 85 | padding: 0 14px 10px 0; 86 | } 87 | } 88 | 89 | .learnMore { 90 | width: 100%; 91 | margin-bottom: 80px; 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/infoStringHelpers.js: -------------------------------------------------------------------------------- 1 | export const currentPositionString = prev => 2 | prev ? ` (${prev[0]},${prev[1]})` : ''; 3 | 4 | const optPlus = coord => (typeof coord === 'number' && coord < 0 ? '' : '+'); 5 | 6 | export const relativeToString = (v, prev, names = ['x', 'y']) => { 7 | if (v.every(coord => typeof coord === 'number') && prev) { 8 | return `${prev[0] + v[0]},${prev[1] + v[1]} (${prev[0]}${optPlus(v[0])}${ 9 | v[0] 10 | }, ${prev[1]}${optPlus(v[1])}${v[1]})`; 11 | } else if (prev) { 12 | return `${prev[0]}${optPlus(v[0])}${v[0]}, ${prev[1]}${optPlus(v[1])}${ 13 | v[1] 14 | }`; 15 | } else { 16 | return `(current ${names[0]}+${v[0]}, current ${names[1]}+${v[1]})`; 17 | } 18 | }; 19 | 20 | export const relativeHorizontalToString = (v, prev) => { 21 | if (v.every(coord => typeof coord === 'number') && prev) { 22 | return `${prev[0] + v[0]},${prev[1]} (${prev[0]}${optPlus(v[0])}${v[0]}, ${ 23 | prev[1] 24 | })`; 25 | } else if (prev) { 26 | return `${prev[0]}${optPlus(v[0])}${v[0]}, ${prev[1]}`; 27 | } else { 28 | return `(current x${optPlus(v[0])}${v[0]}, current y)`; 29 | } 30 | }; 31 | 32 | export const relativeVerticalToString = (v, prev) => { 33 | if (v.every(coord => typeof coord === 'number') && prev) { 34 | return `${prev[0]},${prev[1] + v[0]} (${prev[0]},${optPlus(v[0])}${v[0]})`; 35 | } else if (prev) { 36 | return `${prev[0]}, ${prev[1]}${optPlus(v[0])}${v[0]}`; 37 | } else { 38 | return `(current x, current y${optPlus(v[0])}${v[0]})`; 39 | } 40 | }; 41 | 42 | export const arcFlagString = largeArc => { 43 | if (typeof largeArc === 'number' && [0, 1].includes(largeArc)) { 44 | return `using the ${largeArc === 0 ? 'smaller' : 'larger'} of the two arcs`; 45 | } else { 46 | `with large-arc-flag (0 or 1) deciding between the smaller or larger arc`; 47 | } 48 | }; 49 | 50 | export const sweepFlagString = sweep => { 51 | if (typeof sweep === 'number' && [0, 1].includes(sweep)) { 52 | return `using the ${sweep === 0 ? 'counterclockwise' : 'clockwise'} arc`; 53 | } else { 54 | `with sweep-flag (0 or 1) deciding between the counterclockwise or clockwise arc`; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/paste_box.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { COMMANDS, DIGIT } from '../utils/constants'; 3 | import withUuids from '../utils/withUuids'; 4 | 5 | import styles from '../styles/paste_box.module.scss'; 6 | 7 | export default function PasteBox({ setInstructions }) { 8 | const pasteBoxRef = useRef(null); 9 | 10 | useEffect(() => { 11 | if (pasteBoxRef.current) { 12 | pasteBoxRef.current.focus(); 13 | } 14 | }, [pasteBoxRef]); 15 | 16 | const handlePasteChange = e => { 17 | const input = e.target.value; 18 | if (input.trim() === '') return; 19 | 20 | const commandChars = Object.keys(COMMANDS).join(''); 21 | const parts = ( 22 | (input.replace(/-/g, ' -') + ' ').match( 23 | new RegExp(`[${commandChars}][^${commandChars}]+`, 'gi') 24 | ) || [] 25 | ) 26 | .map(part => { 27 | const letter = part[0]; 28 | 29 | if (letter.toUpperCase() === 'Z') { 30 | return ['Z']; 31 | } 32 | const rest = part.slice(1).trim(); 33 | const coords = (rest.match(new RegExp(DIGIT, 'g')) || []).map(coord => 34 | coord.replace(/^\./, '0.').replace('-.', '-0.') 35 | ); 36 | if (coords.some(coord => coord.trim() === '')) { 37 | return null; 38 | } 39 | const groupLength = COMMANDS[letter.toUpperCase()].partNames.length; 40 | const grouped = coords.reduce((groups, coord, i) => { 41 | i % groupLength !== 0 42 | ? groups[groups.length - 1].push(coord) 43 | : groups.push([coord]); 44 | return groups; 45 | }, []); 46 | if (grouped.some(group => group.length !== groupLength)) { 47 | return null; 48 | } 49 | return grouped.map((group, i) => { 50 | let implicitLetter = letter; 51 | if (i !== 0 && letter.toUpperCase() === 'M') { 52 | implicitLetter = { m: 'l', M: 'L' }[letter]; 53 | } 54 | return `${implicitLetter}${group.join(',')}`; 55 | }); 56 | }) 57 | .flat(1); 58 | 59 | if (parts.length > 0 && parts.every(Boolean)) { 60 | setInstructions(withUuids(parts)); 61 | } 62 | }; 63 | 64 | return ( 65 |