Created
November 26, 2025 13:59
-
-
Save nsdevaraj/09b0f6056499d28917d8ac5108074b7b to your computer and use it in GitHub Desktop.
svg editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html lang="en"><head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Vector Studio - SVG Editor & 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&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 && 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 && 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 && 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 && 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