Skip to content

Instantly share code, notes, and snippets.

@nsdevaraj
Created November 26, 2025 13:59
Show Gist options
  • Select an option

  • Save nsdevaraj/09b0f6056499d28917d8ac5108074b7b to your computer and use it in GitHub Desktop.

Select an option

Save nsdevaraj/09b0f6056499d28917d8ac5108074b7b to your computer and use it in GitHub Desktop.
svg editor
<html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vector Studio - SVG Editor &amp; Tracer</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js -->
<script defer="" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<!-- Google Fonts & Icons -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0">
<style>
body { font-family: 'Inter', sans-serif; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.canvas-grid {
background-color: #ffffff;
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.tool-btn { @apply p-2 rounded-lg text-gray-600 hover:bg-blue-50 hover:text-blue-600 transition-colors flex flex-col items-center justify-center gap-1 text-xs; }
.tool-btn.active { @apply bg-blue-100 text-blue-700 ring-2 ring-blue-500 ring-offset-1; }
.range-slider { @apply w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer; }
.range-slider::-webkit-slider-thumb { @apply appearance-none w-4 h-4 rounded-full bg-blue-500 cursor-pointer hover:bg-blue-400 transition-colors; }
/* Light slider for editor panel */
.light-slider { @apply w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer; }
.light-slider::-webkit-slider-thumb { @apply appearance-none w-4 h-4 rounded-full bg-blue-600 cursor-pointer hover:bg-blue-700 transition-colors; }
</style>
<style>*, ::before, ::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/* ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com */*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0px}.-top-12{top:-3rem}.left-0{left:0px}.top-0{top:0px}.z-10{z-index:10}.z-20{z-index:20}.my-1{margin-top:0.25rem;margin-bottom:0.25rem}.mb-1{margin-bottom:0.25rem}.mb-2{margin-bottom:0.5rem}.mb-3{margin-bottom:0.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:0.5rem}.mt-2{margin-top:0.5rem}.mt-auto{margin-top:auto}.block{display:block}.flex{display:flex}.h-14{height:3.5rem}.h-8{height:2rem}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[80vh\]{max-height:80vh}.min-h-\[300px\]{min-height:300px}.w-10{width:2.5rem}.w-14{width:3.5rem}.w-20{width:5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-full{width:100%}.min-w-\[400px\]{min-width:400px}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite}.cursor-crosshair{cursor:crosshair}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;user-select:none}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:0.25rem}.gap-2{gap:0.5rem}.gap-3{gap:0.75rem}.space-y-1 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0.25rem * var(--tw-space-y-reverse))}.space-y-2 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0.5rem * var(--tw-space-y-reverse))}.space-y-3 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0.75rem * var(--tw-space-y-reverse))}.space-y-4 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:0.25rem}.rounded-2xl{border-radius:1rem}.rounded-lg{border-radius:0.5rem}.rounded-md{border-radius:0.375rem}.rounded-xl{border-radius:0.75rem}.border{border-width:1px}.border-0{border-width:0px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-dashed{border-style:dashed}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246 / var(--tw-border-opacity, 1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:rgb(255 255 255 / 0.1)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-slate-700\/50{background-color:rgb(51 65 85 / 0.5)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/5{background-color:rgb(255 255 255 / 0.05)}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-\[radial-gradient\(ellipse_at_center\2c _var\(--tw-gradient-stops\)\)\]{background-image:radial-gradient(ellipse at center, var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right, var(--tw-gradient-stops))}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgb(37 99 235 / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-slate-800{--tw-gradient-from:#1e293b var(--tw-gradient-from-position);--tw-gradient-to:rgb(30 41 59 / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.to-indigo-700{--tw-gradient-to:#4338ca var(--tw-gradient-to-position)}.to-slate-900{--tw-gradient-to:#0f172a var(--tw-gradient-to-position)}.p-0{padding:0px}.p-1{padding:0.25rem}.p-1\.5{padding:0.375rem}.p-10{padding:2.5rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.p-3{padding:0.75rem}.px-3{padding-left:0.75rem;padding-right:0.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:0.25rem;padding-bottom:0.25rem}.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem}.py-3{padding-top:0.75rem;padding-bottom:0.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:0.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:0.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.tracking-tight{letter-spacing:-0.025em}.tracking-wider{letter-spacing:0.05em}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.opacity-0{opacity:0}.opacity-60{opacity:0.6}.opacity-80{opacity:0.8}.mix-blend-screen{mix-blend-mode:screen}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgb(0 0 0 / 0.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgb(0 0 0 / 0.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-blue-900\/30{--tw-shadow-color:rgb(30 58 138 / 0.3);--tw-shadow:var(--tw-shadow-colored)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.ring-white\/20{--tw-ring-color:rgb(255 255 255 / 0.2)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms}.transition-colors{transition-property:color, background-color, border-color, fill, stroke, -webkit-text-decoration-color;transition-property:color, background-color, border-color, text-decoration-color, fill, stroke;transition-property:color, background-color, border-color, text-decoration-color, fill, stroke, -webkit-text-decoration-color;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-emerald-500:hover{--tw-bg-opacity:1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-900:hover{--tw-bg-opacity:1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-700\/50:hover{background-color:rgb(51 65 85 / 0.5)}.hover\:bg-slate-600:hover{--tw-bg-opacity:1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.hover\:opacity-80:hover{opacity:0.8}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:0.5}.group:hover .group-hover\:border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.group:hover .group-hover\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}</style></head>
<body class="bg-gray-100 h-screen overflow-hidden flex flex-col" x-data="app()">
<!-- Header -->
<header class="bg-white border-b border-gray-200 h-14 flex items-center justify-between px-4 z-20 shadow-sm shrink-0">
<div class="flex items-center gap-3">
<div class="bg-gradient-to-br from-blue-600 to-indigo-700 text-white p-1.5 rounded-lg shadow-md">
<span class="material-symbols-outlined text-xl leading-none">edit_square</span>
</div>
<h1 class="font-bold text-gray-800 text-lg tracking-tight">Vector<span class="text-blue-600">Studio</span></h1>
</div>
<div class="flex bg-gray-100 p-1 rounded-lg">
<button @click="view = 'editor'" :class="view === 'editor' ? 'bg-white text-blue-700 shadow-sm font-semibold' : 'text-gray-500 hover:text-gray-700'" class="px-4 py-1.5 rounded-md text-sm transition-all flex items-center gap-2 bg-white text-blue-700 shadow-sm font-semibold">
<span class="material-symbols-outlined text-lg">draw</span> Editor
</button>
<button @click="view = 'tracer'" :class="view === 'tracer' ? 'bg-white text-blue-700 shadow-sm font-semibold' : 'text-gray-500 hover:text-gray-700'" class="px-4 py-1.5 rounded-md text-sm transition-all flex items-center gap-2 text-gray-500 hover:text-gray-700">
<span class="material-symbols-outlined text-lg js-replaced-missing-icon">radio_button_unchecked</span> Image Tracer
</button>
</div>
<div class="flex items-center gap-2">
<button @click="exportMainSVG()" class="px-3 py-1.5 bg-gray-800 text-white text-sm rounded-lg hover:bg-gray-900 flex items-center gap-2 shadow-sm transition-colors">
<span class="material-symbols-outlined text-lg">download</span> Export SVG
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex overflow-hidden">
<!-- EDITOR VIEW -->
<div class="flex-1 flex w-full h-full" x-show="view === 'editor'">
<!-- Tools -->
<aside class="w-20 bg-white border-r border-gray-200 flex flex-col items-center py-4 gap-2 z-10 shadow-sm shrink-0">
<button @click="setTool('select')" :class="{'active': tool === 'select'}" class="tool-btn w-14 h-14 active" title="Select (V)">
<span class="material-symbols-outlined text-2xl">arrow_selector_tool</span>
<span>Select</span>
</button>
<div class="w-10 h-px bg-gray-200 my-1"></div>
<button @click="setTool('rect')" :class="{'active': tool === 'rect'}" class="tool-btn w-14 h-14" title="Rectangle (R)">
<span class="material-symbols-outlined text-2xl">rectangle</span>
<span>Rect</span>
</button>
<button @click="setTool('circle')" :class="{'active': tool === 'circle'}" class="tool-btn w-14 h-14" title="Circle (C)">
<span class="material-symbols-outlined text-2xl">circle</span>
<span>Circle</span>
</button>
<button @click="setTool('pen')" :class="{'active': tool === 'pen'}" class="tool-btn w-14 h-14" title="Pen Tool (P)">
<span class="material-symbols-outlined text-2xl">edit_attributes</span>
<span>Pen</span>
</button>
<div class="flex-1"></div>
<button @click="clearEditor()" class="tool-btn w-14 h-14 text-red-500 hover:bg-red-50 hover:text-red-600" title="Clear Canvas">
<span class="material-symbols-outlined text-2xl">delete</span>
</button>
</aside>
<!-- Canvas -->
<div class="flex-1 relative bg-gray-50 overflow-auto flex items-center justify-center p-8">
<div class="relative bg-white shadow-lg canvas-grid" :style="`width: ${docWidth}px; height: ${docHeight}px`" style="width: 800px; height: 600px">
<svg id="main-svg" :width="docWidth" :height="docHeight" class="absolute inset-0 w-full h-full cursor-crosshair select-none" @mousedown="handleMouseDown" @mousemove="handleMouseMove" @mouseup="handleMouseUp" @mouseleave="handleMouseUp" width="800" height="600">
<!-- Main Elements -->
<template x-for="el in elements" :key="el.id">
<g :class="{'opacity-50': draggingId === el.id}">
<template x-if="el.type === 'path'">
<path :d="el.d" :fill="el.fill" :stroke="el.stroke" :stroke-width="el.strokeWidth" :transform="`translate(${el.x}, ${el.y})`" :class="{'ring-2 ring-blue-500': selectedId === el.id}" class="hover:opacity-80 transition-opacity" @mousedown.stop="selectElement(el.id, $event)" d="" fill="" stroke="" stroke-width="" transform=""></path>
</template>
<template x-if="el.type === 'rect'">
<rect :x="el.x" :y="el.y" :width="el.w" :height="el.h" :fill="el.fill" :stroke="el.stroke" :stroke-width="el.strokeWidth" class="hover:opacity-80 transition-opacity" @mousedown.stop="selectElement(el.id, $event)" x="" y="" width="" height="" fill="" stroke="" stroke-width=""></rect>
</template>
<template x-if="el.type === 'circle'">
<circle :cx="el.x" :cy="el.y" :r="el.r" :fill="el.fill" :stroke="el.stroke" :stroke-width="el.strokeWidth" class="hover:opacity-80 transition-opacity" @mousedown.stop="selectElement(el.id, $event)" cx="" cy="" r="" fill="" stroke="" stroke-width=""></circle>
</template>
</g>
</template>
<!-- Drawing Preview -->
<template x-if="isDrawing &amp;&amp; currentShape">
<g class="pointer-events-none opacity-60">
<template x-if="tool === 'rect'">
<rect :x="currentShape.x" :y="currentShape.y" :width="currentShape.w" :height="currentShape.h" :fill="fillColor" :stroke="strokeColor" :stroke-width="strokeWidth" fill="#3b82f6" stroke="#1e40af" stroke-width="2" x="" y="" width="" height=""></rect>
</template>
<template x-if="tool === 'circle'">
<circle :cx="currentShape.x" :cy="currentShape.y" :r="currentShape.r" :fill="fillColor" :stroke="strokeColor" :stroke-width="strokeWidth" fill="#3b82f6" stroke="#1e40af" stroke-width="2" cx="" cy="" r=""></circle>
</template>
<template x-if="tool === 'pen'">
<path :d="currentShape.d" fill="none" :stroke="strokeColor" :stroke-width="strokeWidth" stroke="#1e40af" stroke-width="2" d=""></path>
</template>
</g>
</template>
<!-- Selection Indicator -->
<template x-if="selectedElement">
<rect :x="getBoundingBox(selectedElement).x - 2" :y="getBoundingBox(selectedElement).y - 2" :width="getBoundingBox(selectedElement).w + 4" :height="getBoundingBox(selectedElement).h + 4" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="4 4" class="pointer-events-none" x="-2" y="-2" width="4" height="4"></rect>
</template>
</svg>
</div>
</div>
<!-- Properties Panel -->
<aside class="w-64 bg-white border-l border-gray-200 flex flex-col shrink-0 z-10">
<div class="p-4 border-b border-gray-100">
<h3 class="font-semibold text-gray-800 text-sm mb-4 flex items-center gap-2">
<span class="material-symbols-outlined text-gray-500">tune</span> Properties
</h3>
<div class="space-y-4">
<div>
<label class="text-xs font-medium text-gray-500 block mb-1">Fill Color</label>
<div class="flex items-center gap-2">
<input type="color" x-model="fillColor" @input="updateSelected('fill', fillColor)" class="w-8 h-8 rounded cursor-pointer border-0 p-0">
<span class="text-xs text-gray-600 font-mono uppercase" x-text="fillColor">#3b82f6</span>
<button @click="fillColor = 'transparent'; updateSelected('fill', 'none')" class="ml-auto text-xs text-gray-400 hover:text-red-500">None</button>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-500 block mb-1">Stroke Color</label>
<div class="flex items-center gap-2">
<input type="color" x-model="strokeColor" @input="updateSelected('stroke', strokeColor)" class="w-8 h-8 rounded cursor-pointer border-0 p-0">
<span class="text-xs text-gray-600 font-mono uppercase" x-text="strokeColor">#1e40af</span>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-500 block mb-1">Stroke Width: <span x-text="strokeWidth + 'px'">2px</span></label>
<input type="range" min="0" max="20" step="1" x-model="strokeWidth" @input="updateSelected('strokeWidth', strokeWidth)" class="light-slider">
</div>
</div>
</div>
<div class="p-4 bg-gray-50 flex-1 overflow-y-auto">
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Layers</h4>
<div class="space-y-1">
<template x-for="(el, index) in elements.slice().reverse()" :key="el.id">
<div @click="selectedId = el.id" :class="selectedId === el.id ? 'bg-blue-100 text-blue-700 border-blue-200' : 'bg-white border-gray-200 hover:bg-gray-100'" class="p-2 rounded-md border text-xs cursor-pointer flex items-center justify-between group">
<span class="flex items-center gap-2">
<span class="material-symbols-outlined text-base opacity-50" x-text="el.type === 'path' ? 'gesture' : (el.type === 'rect' ? 'rectangle' : 'circle')"></span>
<span x-text="el.type + ' ' + (elements.length - index)"></span>
</span>
<button @click.stop="removeElement(el.id)" class="opacity-0 group-hover:opacity-100 hover:text-red-500">
<span class="material-symbols-outlined text-base">close</span>
</button>
</div>
</template>
<div x-show="elements.length === 0" class="text-center py-8 text-gray-400 text-xs italic">
No elements. Draw something!
</div>
</div>
</div>
</aside>
</div>
<!-- TRACER VIEW -->
<div class="flex-1 flex w-full h-full bg-slate-900 text-white" x-show="view === 'tracer'" style="display: none;">
<!-- Tracer Sidebar -->
<div class="w-80 bg-slate-800 border-r border-slate-700 flex flex-col p-5 overflow-y-auto shrink-0 custom-scrollbar">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-blue-400 js-replaced-missing-icon">radio_button_unchecked</span> Trace Settings
</h2>
<!-- Upload -->
<div class="mb-8">
<label class="block text-sm font-medium text-slate-400 mb-2">Source Image</label>
<div class="relative group">
<input type="file" accept="image/*" @change="tracer.handleImageUpload($event)" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div class="border-2 border-dashed border-slate-600 rounded-lg p-4 text-center hover:bg-slate-700/50 transition-colors group-hover:border-blue-500">
<span class="material-symbols-outlined text-3xl text-slate-500 mb-1 group-hover:text-blue-400">cloud_upload</span>
<p class="text-xs text-slate-400 group-hover:text-white">Click or Drop Image</p>
</div>
</div>
<div x-show="tracer.imageDimensions" class="mt-2 text-xs text-blue-300 flex items-center gap-1" style="display: none;">
<span class="material-symbols-outlined text-sm">image</span>
<span x-text="tracer.imageDimensions"></span>
</div>
</div>
<!-- Algorithms -->
<div class="mb-8">
<h3 class="text-sm font-semibold text-white mb-3">Algorithm</h3>
<div class="space-y-2">
<template x-for="algo in ['sobel', 'canny', 'prewitt']">
<button @click="tracer.detectEdges(algo)" :disabled="!tracer.originalImage" :class="tracer.currentAlgorithm === algo ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'" class="w-full p-3 rounded-lg text-left transition-all flex items-center justify-between group disabled:opacity-50 disabled:cursor-not-allowed">
<span class="capitalize font-medium" x-text="algo + ' Operator'"></span>
<span class="material-symbols-outlined text-sm" x-show="tracer.currentAlgorithm === algo">check_circle</span>
</button>
</template><button @click="tracer.detectEdges(algo)" :disabled="!tracer.originalImage" :class="tracer.currentAlgorithm === algo ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'" class="w-full p-3 rounded-lg text-left transition-all flex items-center justify-between group disabled:opacity-50 disabled:cursor-not-allowed bg-slate-700 text-slate-300 hover:bg-slate-600" disabled="disabled">
<span class="capitalize font-medium" x-text="algo + ' Operator'">sobel Operator</span>
<span class="material-symbols-outlined text-sm" x-show="tracer.currentAlgorithm === algo" style="display: none;">check_circle</span>
</button><button @click="tracer.detectEdges(algo)" :disabled="!tracer.originalImage" :class="tracer.currentAlgorithm === algo ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'" class="w-full p-3 rounded-lg text-left transition-all flex items-center justify-between group disabled:opacity-50 disabled:cursor-not-allowed bg-slate-700 text-slate-300 hover:bg-slate-600" disabled="disabled">
<span class="capitalize font-medium" x-text="algo + ' Operator'">canny Operator</span>
<span class="material-symbols-outlined text-sm" x-show="tracer.currentAlgorithm === algo" style="display: none;">check_circle</span>
</button><button @click="tracer.detectEdges(algo)" :disabled="!tracer.originalImage" :class="tracer.currentAlgorithm === algo ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'" class="w-full p-3 rounded-lg text-left transition-all flex items-center justify-between group disabled:opacity-50 disabled:cursor-not-allowed bg-slate-700 text-slate-300 hover:bg-slate-600" disabled="disabled">
<span class="capitalize font-medium" x-text="algo + ' Operator'">prewitt Operator</span>
<span class="material-symbols-outlined text-sm" x-show="tracer.currentAlgorithm === algo" style="display: none;">check_circle</span>
</button>
</div>
</div>
<!-- Canny Params -->
<div x-show="tracer.currentAlgorithm === 'canny'" class="mb-8 p-4 bg-slate-700/50 rounded-lg border border-slate-600 space-y-4" style="display: none;">
<h3 class="text-xs font-bold text-slate-400 uppercase">Canny Thresholds</h3>
<div>
<div class="flex justify-between text-xs mb-1 text-slate-300">
<span>Low Threshold</span> <span x-text="tracer.cannyLow">50</span>
</div>
<input type="range" min="0" max="255" x-model.number="tracer.cannyLow" @change="tracer.detectEdges('canny')" class="range-slider">
</div>
<div>
<div class="flex justify-between text-xs mb-1 text-slate-300">
<span>High Threshold</span> <span x-text="tracer.cannyHigh">150</span>
</div>
<input type="range" min="0" max="255" x-model.number="tracer.cannyHigh" @change="tracer.detectEdges('canny')" class="range-slider">
</div>
</div>
<!-- Generation -->
<div class="mt-auto">
<div x-show="tracer.hasEdges" class="mb-4 space-y-3" style="display: none;">
<div>
<div class="flex justify-between text-xs mb-1 text-slate-300">
<span>Path Simplification</span> <span x-text="tracer.svgSimplification">2</span>
</div>
<input type="range" min="0.5" max="5.0" step="0.5" x-model.number="tracer.svgSimplification" class="range-slider">
</div>
<button @click="tracer.generateSVG()" class="w-full py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium shadow-lg transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined">polyline</span> Generate Paths
</button>
</div>
<button x-show="tracer.hasSVG" @click="importTracedToEditor()" class="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-lg shadow-xl shadow-blue-900/30 transition-all transform hover:scale-[1.02] flex items-center justify-center gap-2 animate-pulse" style="display: none;">
<span class="material-symbols-outlined">add_to_photos</span>
Send to Editor
</button>
</div>
</div>
<!-- Tracer Canvas Area -->
<div class="flex-1 p-8 overflow-auto flex items-center justify-center bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-800 to-slate-900">
<div class="bg-white/5 rounded-2xl p-4 shadow-2xl backdrop-blur-sm border border-white/10 relative">
<!-- Toggles -->
<div class="absolute -top-12 left-0 flex bg-slate-800 rounded-lg p-1 border border-slate-600">
<button @click="tracer.showOriginal = !tracer.showOriginal" :class="tracer.showOriginal ? 'bg-slate-600 text-white' : 'text-slate-400'" class="px-3 py-1 rounded text-xs transition-colors bg-slate-600 text-white">Original</button>
<button @click="tracer.showEdges = !tracer.showEdges" :class="tracer.showEdges ? 'bg-slate-600 text-white' : 'text-slate-400'" class="px-3 py-1 rounded text-xs transition-colors bg-slate-600 text-white">Edges</button>
<button @click="tracer.showSVG = !tracer.showSVG" :class="tracer.showSVG ? 'bg-slate-600 text-white' : 'text-slate-400'" class="px-3 py-1 rounded text-xs transition-colors bg-slate-600 text-white">Vector</button>
</div>
<div class="relative rounded-lg overflow-hidden ring-1 ring-white/20 bg-black min-w-[400px] min-h-[300px] flex items-center justify-center">
<!-- Empty State -->
<div x-show="!tracer.originalImage" class="text-center p-10">
<span class="material-symbols-outlined text-6xl text-slate-700 mb-4">image_search</span>
<p class="text-slate-500">Upload an image to start tracing</p>
</div>
<!-- Canvases -->
<canvas x-ref="originalCanvas" x-show="tracer.showOriginal &amp;&amp; tracer.originalImage" class="absolute top-0 left-0 max-w-full max-h-[80vh]" style="display: none;"></canvas>
<canvas x-ref="edgeCanvas" x-show="tracer.showEdges &amp;&amp; tracer.hasEdges" class="absolute top-0 left-0 max-w-full max-h-[80vh] mix-blend-screen opacity-80" style="display: none;"></canvas>
<div x-ref="svgContainer" x-show="tracer.showSVG &amp;&amp; tracer.hasSVG" class="absolute top-0 left-0 max-w-full max-h-[80vh] z-10 pointer-events-none" style="display: none;"></div>
</div>
</div>
</div>
</div>
</main>
<script>
function app() {
return {
view: 'editor',
// --- Editor State ---
docWidth: 800,
docHeight: 600,
elements: [],
tool: 'select',
fillColor: '#3b82f6',
strokeColor: '#1e40af',
strokeWidth: 2,
selectedId: null,
isDrawing: false,
startPos: { x: 0, y: 0 },
currentShape: null,
draggingId: null,
dragOffset: { x: 0, y: 0 },
penPoints: [],
init() {
this.tracer.init(this.$refs);
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'v') this.setTool('select');
if (e.key === 'r') this.setTool('rect');
if (e.key === 'c') this.setTool('circle');
if (e.key === 'p') this.setTool('pen');
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedId) this.removeElement(this.selectedId);
}
});
},
setTool(t) {
this.tool = t;
this.selectedId = null;
},
getMousePos(e) {
const rect = document.getElementById('main-svg').getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
},
handleMouseDown(e) {
if (this.view !== 'editor') return;
const pos = this.getMousePos(e);
if (this.tool === 'select') {
if (e.target.id === 'main-svg') this.selectedId = null;
return;
}
this.isDrawing = true;
this.startPos = pos;
this.penPoints = [pos];
if (this.tool === 'rect') this.currentShape = { x: pos.x, y: pos.y, w: 0, h: 0 };
else if (this.tool === 'circle') this.currentShape = { x: pos.x, y: pos.y, r: 0 };
else if (this.tool === 'pen') this.currentShape = { d: `M ${pos.x} ${pos.y}` };
},
handleMouseMove(e) {
if (!this.isDrawing && !this.draggingId) return;
const pos = this.getMousePos(e);
if (this.draggingId) {
const el = this.elements.find(x => x.id === this.draggingId);
if (el) {
if(el.type === 'path') {
el.x = pos.x - this.dragOffset.x;
el.y = pos.y - this.dragOffset.y;
} else {
el.x = pos.x - this.dragOffset.x;
el.y = pos.y - this.dragOffset.y;
}
}
return;
}
if (this.isDrawing) {
if (this.tool === 'rect') {
const w = pos.x - this.startPos.x;
const h = pos.y - this.startPos.y;
this.currentShape.x = w < 0 ? pos.x : this.startPos.x;
this.currentShape.y = h < 0 ? pos.y : this.startPos.y;
this.currentShape.w = Math.abs(w);
this.currentShape.h = Math.abs(h);
} else if (this.tool === 'circle') {
const dx = pos.x - this.startPos.x;
const dy = pos.y - this.startPos.y;
this.currentShape.r = Math.sqrt(dx*dx + dy*dy);
} else if (this.tool === 'pen') {
this.penPoints.push(pos);
this.currentShape.d += ` L ${pos.x} ${pos.y}`;
}
}
},
handleMouseUp(e) {
if (this.draggingId) { this.draggingId = null; return; }
if (!this.isDrawing) return;
this.isDrawing = false;
const id = Date.now();
let newEl = null;
if (this.tool === 'rect' && this.currentShape.w > 2) {
newEl = { id, type: 'rect', ...this.currentShape, fill: this.fillColor, stroke: this.strokeColor, strokeWidth: this.strokeWidth };
} else if (this.tool === 'circle' && this.currentShape.r > 2) {
newEl = { id, type: 'circle', ...this.currentShape, fill: this.fillColor, stroke: this.strokeColor, strokeWidth: this.strokeWidth };
} else if (this.tool === 'pen' && this.penPoints.length > 1) {
newEl = { id, type: 'path', d: this.currentShape.d, x:0, y:0, fill: 'none', stroke: this.strokeColor, strokeWidth: this.strokeWidth };
}
if (newEl) {
this.elements.push(newEl);
this.selectedId = id;
}
this.currentShape = null;
},
selectElement(id, e) {
if (this.tool !== 'select') return;
this.selectedId = id;
this.draggingId = id;
const el = this.elements.find(x => x.id === id);
const pos = this.getMousePos(e);
if (el.type === 'path') this.dragOffset = { x: pos.x - (el.x || 0), y: pos.y - (el.y || 0) };
else this.dragOffset = { x: pos.x - el.x, y: pos.y - el.y };
},
get selectedElement() { return this.elements.find(el => el.id === this.selectedId); },
updateSelected(prop, value) {
if (this.selectedId) {
const el = this.elements.find(x => x.id === this.selectedId);
if (el) el[prop] = value;
}
},
removeElement(id) {
this.elements = this.elements.filter(x => x.id !== id);
if (this.selectedId === id) this.selectedId = null;
},
clearEditor() { if(confirm('Clear all elements?')) this.elements = []; },
getBoundingBox(el) {
if (!el) return {x:0, y:0, w:0, h:0};
if (el.type === 'rect') return { x: el.x, y: el.y, w: el.w, h: el.h };
if (el.type === 'circle') return { x: el.x - el.r, y: el.y - el.r, w: el.r*2, h: el.r*2 };
return {x:0,y:0,w:0,h:0};
},
exportMainSVG() {
const svgData = document.getElementById('main-svg').outerHTML;
const blob = new Blob([svgData], {type: 'image/svg+xml'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vector-studio.svg';
a.click();
},
downloadSourceCode() {
const html = document.documentElement.outerHTML;
const blob = new Blob([html], {type: 'text/html'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vector-studio-app.html';
a.click();
},
importTracedToEditor() {
if (!this.tracer.svgPaths || this.tracer.svgPaths.length === 0) return;
const imgW = this.tracer.edgeCanvas.width;
const imgH = this.tracer.edgeCanvas.height;
const offsetX = (this.docWidth - imgW) / 2;
const offsetY = (this.docHeight - imgH) / 2;
let combinedD = "";
this.tracer.svgPaths.forEach(p => { combinedD += ` ${p.d}`; });
const newEl = {
id: Date.now(),
type: 'path',
d: combinedD,
x: offsetX > 0 ? offsetX : 0,
y: offsetY > 0 ? offsetY : 0,
fill: 'none',
stroke: '#000000',
strokeWidth: 1.5
};
this.elements.push(newEl);
this.view = 'editor';
this.selectedId = newEl.id;
this.tool = 'select';
},
tracer: {
originalImage: null,
originalCanvas: null,
edgeCanvas: null,
edgeData: null,
svgPaths: [],
currentAlgorithm: '',
imageDimensions: '',
hasEdges: false,
hasSVG: false,
showOriginal: true,
showEdges: true,
showSVG: true,
cannyLow: 50,
cannyHigh: 150,
svgSimplification: 2.0,
init(refs) {
this.originalCanvas = refs.originalCanvas;
this.edgeCanvas = refs.edgeCanvas;
this.svgContainer = refs.svgContainer;
},
handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
this.originalImage = img;
this.drawOriginalImage(img);
this.imageDimensions = `${img.width} × ${img.height}`;
this.edgeCanvas.width = img.width;
this.edgeCanvas.height = img.height;
this.hasEdges = false;
this.hasSVG = false;
this.svgPaths = [];
this.svgContainer.innerHTML = '';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
},
drawOriginalImage(img) {
this.originalCanvas.width = img.width;
this.originalCanvas.height = img.height;
const ctx = this.originalCanvas.getContext('2d');
ctx.drawImage(img, 0, 0);
},
detectEdges(algorithm) {
if (!this.originalImage) return;
this.currentAlgorithm = algorithm;
const ctx = this.originalCanvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, this.originalCanvas.width, this.originalCanvas.height);
let edgeData;
switch (algorithm) {
case 'sobel': edgeData = this.sobelEdgeDetection(imageData); break;
case 'canny': edgeData = this.cannyEdgeDetection(imageData); break;
case 'prewitt': edgeData = this.prewittEdgeDetection(imageData); break;
}
this.edgeData = edgeData;
this.displayEdgeData(edgeData);
this.hasEdges = true;
this.hasSVG = false;
this.svgContainer.innerHTML = '';
},
sobelEdgeDetection(imageData) {
const { data, width, height } = imageData;
const output = new Uint8ClampedArray(data.length);
const grayscale = this.toGrayscale(data);
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
this.convolve(grayscale, width, height, sobelX, sobelY, output);
return output;
},
prewittEdgeDetection(imageData) {
const { data, width, height } = imageData;
const output = new Uint8ClampedArray(data.length);
const grayscale = this.toGrayscale(data);
const prewittX = [-1, 0, 1, -1, 0, 1, -1, 0, 1];
const prewittY = [-1, -1, -1, 0, 0, 0, 1, 1, 1];
this.convolve(grayscale, width, height, prewittX, prewittY, output);
return output;
},
cannyEdgeDetection(imageData) {
const { data, width, height } = imageData;
const grayscale = new Float32Array(width * height);
for (let i = 0; i < data.length; i += 4) {
grayscale[i / 4] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
}
const blurred = this.gaussianBlur(grayscale, width, height);
const gradients = this.calculateGradients(blurred, width, height);
const nms = this.nonMaximumSuppression(gradients, width, height);
const edges = this.hysteresisThresholding(nms, width, height, this.cannyLow, this.cannyHigh);
const output = new Uint8ClampedArray(data.length);
for (let i = 0; i < edges.length; i++) {
const val = edges[i] * 255;
output[i*4] = output[i*4+1] = output[i*4+2] = val;
output[i*4+3] = 255;
}
return output;
},
toGrayscale(data) {
const gray = new Uint8Array(data.length / 4);
for (let i = 0; i < data.length; i += 4) {
gray[i / 4] = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
}
return gray;
},
convolve(gray, w, h, kx, ky, output) {
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let gx = 0, gy = 0;
for (let ky_ = -1; ky_ <= 1; ky_++) {
for (let kx_ = -1; kx_ <= 1; kx_++) {
const px = gray[(y + ky_) * w + (x + kx_)];
const kidx = (ky_ + 1) * 3 + (kx_ + 1);
gx += px * kx[kidx];
gy += px * ky[kidx];
}
}
const mag = Math.min(255, Math.sqrt(gx * gx + gy * gy));
const idx = (y * w + x) * 4;
output[idx] = output[idx + 1] = output[idx + 2] = mag;
output[idx + 3] = 255;
}
}
},
gaussianBlur(data, w, h) {
const kernel = [1, 2, 1, 2, 4, 2, 1, 2, 1];
const kSum = 16;
const output = new Float32Array(data.length);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let sum = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
sum += data[(y + ky) * w + (x + kx)] * kernel[(ky + 1) * 3 + (kx + 1)];
}
}
output[y * w + x] = sum / kSum;
}
}
return output;
},
calculateGradients(data, w, h) {
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
const mag = new Float32Array(w * h);
const dir = new Float32Array(w * h);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let gx = 0, gy = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const px = data[(y + ky) * w + (x + kx)];
const idx = (ky + 1) * 3 + (kx + 1);
gx += px * sobelX[idx];
gy += px * sobelY[idx];
}
}
mag[y * w + x] = Math.sqrt(gx*gx + gy*gy);
dir[y * w + x] = Math.atan2(gy, gx);
}
}
return { magnitudes: mag, directions: dir };
},
nonMaximumSuppression(grads, w, h) {
const { magnitudes: mag, directions: dir } = grads;
const output = new Float32Array(w * h);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x;
const m = mag[i];
const angle = (dir[i] * 180 / Math.PI + 180) % 180;
let n1 = 0, n2 = 0;
if ((angle >= 0 && angle < 22.5) || (angle >= 157.5)) { n1 = mag[i-1]; n2 = mag[i+1]; }
else if (angle >= 22.5 && angle < 67.5) { n1 = mag[i-w-1]; n2 = mag[i+w+1]; }
else if (angle >= 67.5 && angle < 112.5) { n1 = mag[i-w]; n2 = mag[i+w]; }
else if (angle >= 112.5 && angle < 157.5) { n1 = mag[i-w+1]; n2 = mag[i+w-1]; }
output[i] = (m >= n1 && m >= n2) ? m : 0;
}
}
return output;
},
hysteresisThresholding(nms, w, h, low, high) {
const output = new Uint8Array(w * h);
const strong = 2, weak = 1;
for (let i = 0; i < nms.length; i++) {
if (nms[i] >= high) output[i] = strong;
else if (nms[i] >= low) output[i] = weak;
}
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
if (output[y*w+x] === weak) {
let connected = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (output[(y+dy)*w+(x+dx)] === strong) { connected = true; break; }
}
if (connected) break;
}
if (connected) output[y*w+x] = strong;
}
}
}
for (let i = 0; i < output.length; i++) output[i] = (output[i] === strong) ? 1 : 0;
return output;
},
displayEdgeData(edgeData) {
const ctx = this.edgeCanvas.getContext('2d');
const imageData = ctx.createImageData(this.edgeCanvas.width, this.edgeCanvas.height);
imageData.data.set(edgeData);
ctx.putImageData(imageData, 0, 0);
},
generateSVG() {
if (!this.edgeData) return;
const w = this.edgeCanvas.width;
const h = this.edgeCanvas.height;
const pixels = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (this.edgeData[(y*w+x)*4] > 128) pixels.push({x, y});
}
}
const simplified = this.simplifyEdgePixels(pixels, this.svgSimplification);
this.svgPaths = this.createSVGPaths(simplified);
this.displaySVG();
this.hasSVG = true;
},
simplifyEdgePixels(pixels, tolerance) {
if (pixels.length === 0) return [];
const paths = [];
const visited = new Set();
for (const p of pixels) {
const key = `${p.x},${p.y}`;
if (visited.has(key)) continue;
const path = [];
const stack = [p];
while (stack.length) {
const curr = stack.pop();
const k = `${curr.x},${curr.y}`;
if (visited.has(k)) continue;
visited.add(k);
path.push(curr);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx===0 && dy===0) continue;
const nx = curr.x+dx, ny = curr.y+dy;
if (!visited.has(`${nx},${ny}`) && pixels.some(pix => pix.x===nx && pix.y===ny)) {
stack.push({x:nx, y:ny});
}
}
}
}
if (path.length > 5) paths.push(this.douglasPeucker(path, tolerance));
}
return paths;
},
douglasPeucker(points, tolerance) {
if (points.length <= 2) return points;
let dmax = 0;
let index = 0;
const end = points.length - 1;
for (let i = 1; i < end; i++) {
const d = this.perpendicularDistance(points[i], points[0], points[end]);
if (d > dmax) { index = i; dmax = d; }
}
if (dmax > tolerance) {
const res1 = this.douglasPeucker(points.slice(0, index + 1), tolerance);
const res2 = this.douglasPeucker(points.slice(index), tolerance);
return res1.slice(0, res1.length - 1).concat(res2);
}
return [points[0], points[end]];
},
perpendicularDistance(p, a, b) {
let x = p.x, y = p.y;
let x1 = a.x, y1 = a.y;
let x2 = b.x, y2 = b.y;
let A = x - x1;
let B = y - y1;
let C = x2 - x1;
let D = y2 - y1;
let dot = A * C + B * D;
let len_sq = C * C + D * D;
let param = -1;
if (len_sq !== 0) param = dot / len_sq;
let xx, yy;
if (param < 0) { xx = x1; yy = y1; }
else if (param > 1) { xx = x2; yy = y2; }
else { xx = x1 + param * C; yy = y1 + param * D; }
let dx = x - xx;
let dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
},
createSVGPaths(pixelPaths) {
return pixelPaths.map(path => {
if (path.length < 2) return null;
let d = `M ${path[0].x} ${path[0].y}`;
for (let i = 1; i < path.length; i++) d += ` L ${path[i].x} ${path[i].y}`;
return { d, stroke: '#00e676', 'stroke-width': 1, fill: 'none' };
}).filter(p => p !== null);
},
displaySVG() {
this.svgContainer.innerHTML = '';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', this.edgeCanvas.width);
svg.setAttribute('height', this.edgeCanvas.height);
svg.setAttribute('viewBox', `0 0 ${this.edgeCanvas.width} ${this.edgeCanvas.height}`);
this.svgPaths.forEach(p => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', p.d);
path.setAttribute('stroke', p.stroke);
path.setAttribute('stroke-width', p['stroke-width']);
path.setAttribute('fill', 'none');
svg.appendChild(path);
});
this.svgContainer.appendChild(svg);
}
}
}
}
</script>
</body></html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment