Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Perchance // Img.Edit Pro</title> | |
| <!-- Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;700;800&display=swap" | |
| rel="stylesheet"> | |
| <!-- Icons --> | |
| <script src="https://unpkg.com/@phosphor-icons/web@2.0.0/dist/phosphor.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0c0d10; | |
| --panel: #13151a; | |
| --panel2: #1a1d24; | |
| --border: #2a2d38; | |
| --accent: #7c6ff7; | |
| --accent2: #f7a26f; | |
| --green: #5de8a0; | |
| --red: #f76f6f; | |
| --text: #e2dff8; | |
| --muted: #6b6e82; | |
| --radius: 6px; | |
| --mono: 'DM Mono', 'Courier New', monospace; | |
| --display: 'Syne', sans-serif; | |
| --font-size: 12px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| outline: none; | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--mono); | |
| font-size: var(--font-size); | |
| height: 100vh; | |
| margin: 0; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: var(--panel); | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--accent); | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 16px; | |
| background: var(--panel); | |
| border-bottom: 1px solid var(--border); | |
| height: 50px; | |
| z-index: 10; | |
| } | |
| .logo { | |
| font-family: var(--display); | |
| font-weight: 800; | |
| font-size: 17px; | |
| letter-spacing: -0.5px; | |
| color: var(--text); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .logo span { | |
| color: var(--accent); | |
| } | |
| .anycoder-link { | |
| font-size: 10px; | |
| color: var(--muted); | |
| text-decoration: none; | |
| border: 1px solid var(--border); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| transition: all 0.2s; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--accent); | |
| border-color: var(--accent); | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| button { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| cursor: pointer; | |
| border: none; | |
| border-radius: var(--radius); | |
| transition: all 0.15s; | |
| background: var(--panel2); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| padding: 6px 13px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| button:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| background: var(--panel); | |
| } | |
| button:disabled { | |
| opacity: 0.4; | |
| cursor: default; | |
| } | |
| .btn-primary { | |
| background: var(--accent); | |
| color: #fff; | |
| border: 1px solid transparent; | |
| font-weight: 500; | |
| } | |
| .btn-primary:hover { | |
| background: #9187fa; | |
| border-color: #9187fa; | |
| color: #fff; | |
| } | |
| .btn-danger { | |
| background: var(--red); | |
| color: #fff; | |
| border: 1px solid transparent; | |
| } | |
| .btn-danger:hover { | |
| background: #e85555; | |
| } | |
| .workspace { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .panel-left { | |
| width: 220px; | |
| flex-shrink: 0; | |
| background: var(--panel); | |
| border-right: 1px solid var(--border); | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 12px; | |
| } | |
| .label-caps { | |
| font-size: 9px; | |
| letter-spacing: 1.5px; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| font-family: var(--display); | |
| font-weight: 700; | |
| margin-bottom: 8px; | |
| margin-top: 12px; | |
| } | |
| .tool-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 6px; | |
| } | |
| .tool-btn { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| padding: 8px; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| color: var(--muted); | |
| font-size: 9px; | |
| cursor: pointer; | |
| height: 55px; | |
| font-family: var(--mono); | |
| } | |
| .tool-btn:hover { | |
| border-color: var(--accent); | |
| color: var(--text); | |
| background: var(--panel); | |
| } | |
| .tool-btn.active { | |
| background: #1e1b3a; | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| .tool-btn svg, .tool-btn i { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| .canvas-area { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| position: relative; | |
| background: radial-gradient(ellipse at 20% 20%, rgba(124, 111, 247, .06) 0%, transparent 50%), radial-gradient(ellipse at 80% 80%, rgba(247, 162, 111, .04) 0%, transparent 50%), var(--bg); | |
| background-image: linear-gradient(45deg, #13151a 25%, transparent 25%, transparent 75%, #13151a 75%, #13151a), linear-gradient(45deg, #13151a 25%, transparent 25%, transparent 75%, #13151a 75%, #13151a); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 10px 10px; | |
| } | |
| .canvas-centerer { | |
| margin: auto; | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 100%; | |
| height: 100%; | |
| overflow: auto; | |
| } | |
| .canvas-wrap { | |
| position: relative; | |
| box-shadow: 0 0 0 1px var(--border), 0 20px 60px rgba(0, 0, 0, .5); | |
| border-radius: 2px; | |
| transition: transform 0.1s; | |
| background: transparent; | |
| transform-origin: center center; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| .panel-right { | |
| width: 320px; | |
| flex-shrink: 0; | |
| background: var(--panel); | |
| border-left: 1px solid var(--border); | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .tab-row { | |
| display: flex; | |
| border-bottom: 1px solid var(--border); | |
| overflow-x: auto; | |
| background: var(--panel2); | |
| } | |
| .tab { | |
| flex: 1; | |
| padding: 10px 5px; | |
| font-size: 9px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--muted); | |
| cursor: pointer; | |
| border: none; | |
| background: transparent; | |
| border-bottom: 2px solid transparent; | |
| font-family: var(--mono); | |
| transition: all 0.15s; | |
| white-space: nowrap; | |
| position: relative; | |
| } | |
| .tab:hover { | |
| color: var(--text); | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| .tab.active { | |
| color: var(--accent); | |
| border-bottom-color: var(--accent); | |
| } | |
| .tab-panel { | |
| display: none; | |
| padding: 15px; | |
| } | |
| .tab-panel.active { | |
| display: block; | |
| } | |
| .frow { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| margin-bottom: 12px; | |
| } | |
| .flabel { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 10px; | |
| } | |
| .flabel span:first-child { | |
| color: var(--muted); | |
| } | |
| .fval { | |
| color: var(--accent); | |
| min-width: 30px; | |
| text-align: right; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 3px; | |
| border-radius: 2px; | |
| background: var(--border); | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| cursor: pointer; | |
| transition: transform 0.1s; | |
| } | |
| input[type="range"]:hover::-webkit-slider-thumb { | |
| transform: scale(1.2); | |
| } | |
| input[type="color"] { | |
| width: 100%; | |
| height: 30px; | |
| border: none; | |
| background: transparent; | |
| cursor: pointer; | |
| } | |
| select { | |
| width: 100%; | |
| background: var(--panel2); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 6px 7px; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| outline: none; | |
| cursor: pointer; | |
| margin-bottom: 9px; | |
| } | |
| .ai-card { | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .ai-card p { | |
| font-size: 10px; | |
| color: var(--muted); | |
| line-height: 1.5; | |
| margin: 0; | |
| } | |
| .model-info { | |
| font-size: 9px; | |
| color: var(--accent2); | |
| margin-top: 4px; | |
| font-style: italic; | |
| } | |
| .layer-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .layer-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: var(--panel2); | |
| padding: 6px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border); | |
| position: relative; | |
| cursor: pointer; | |
| } | |
| .layer-item.active { | |
| border-color: var(--accent); | |
| background: #1e1b3a; | |
| } | |
| .layer-thumb { | |
| width: 32px; | |
| height: 32px; | |
| background: #000; | |
| object-fit: cover; | |
| border: 1px solid var(--border); | |
| border-radius: 2px; | |
| } | |
| .layer-info { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .layer-name { | |
| font-size: 10px; | |
| font-weight: 500; | |
| } | |
| .layer-controls { | |
| display: flex; | |
| gap: 4px; | |
| align-items: center; | |
| } | |
| .layer-btn { | |
| padding: 2px 4px; | |
| font-size: 8px; | |
| border: none; | |
| background: transparent; | |
| color: var(--muted); | |
| } | |
| .layer-btn:hover { | |
| color: var(--text); | |
| } | |
| .layer-opacity { | |
| width: 40px; | |
| height: 3px; | |
| } | |
| .drop-zone { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 13px; | |
| z-index: 5; | |
| pointer-events: none; | |
| } | |
| .drop-zone.hidden { | |
| display: none; | |
| } | |
| .dz-ring { | |
| width: 60px; | |
| height: 60px; | |
| border: 2px dashed var(--border); | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 22px; | |
| color: var(--muted); | |
| animation: pulse 2.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { | |
| border-color: var(--border); | |
| transform: scale(1); | |
| } | |
| 50% { | |
| border-color: var(--accent); | |
| transform: scale(1.04); | |
| } | |
| } | |
| #toast { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%) translateY(20px); | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| padding: 7px 18px; | |
| border-radius: 20px; | |
| font-size: 11px; | |
| color: var(--text); | |
| opacity: 0; | |
| transition: all 0.3s; | |
| pointer-events: none; | |
| z-index: 100; | |
| white-space: nowrap; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); | |
| } | |
| #toast.on { | |
| opacity: 1; | |
| transform: translateX(-50%) translateY(0); | |
| } | |
| .zoom-bar { | |
| position: absolute; | |
| bottom: 14px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: none; | |
| align-items: center; | |
| gap: 6px; | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 3px 10px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| z-index: 10; | |
| } | |
| .zoom-bar.on { | |
| display: flex; | |
| } | |
| .zoom-bar button { | |
| background: transparent; | |
| border: none; | |
| color: var(--muted); | |
| padding: 1px 5px; | |
| font-size: 14px; | |
| } | |
| .zoom-bar button:hover { | |
| color: var(--accent); | |
| } | |
| .modal { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.7); | |
| z-index: 50; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-content { | |
| background: var(--panel); | |
| padding: 20px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| width: 300px; | |
| } | |
| .modal-content h3 { | |
| margin: 0 0 10px 0; | |
| font-family: var(--display); | |
| font-size: 14px; | |
| } | |
| .modal-content input[type="text"] { | |
| width: 100%; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| padding: 8px; | |
| margin-bottom: 10px; | |
| font-family: var(--mono); | |
| border-radius: var(--radius); | |
| } | |
| .modal-content button { | |
| width: 100%; | |
| margin-bottom: 5px; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 4px; | |
| background: var(--border); | |
| border-radius: 2px; | |
| margin-top: 8px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| width: 0%; | |
| transition: width 0.3s; | |
| } | |
| .color-presets { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| margin-top: 4px; | |
| } | |
| .color-preset { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| border: 1px solid var(--border); | |
| } | |
| .color-preset:hover { | |
| border-color: var(--accent); | |
| } | |
| .brush-preview { | |
| width: 100%; | |
| height: 40px; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 8px; | |
| } | |
| .brush-preview canvas { | |
| border-radius: 50%; | |
| } | |
| .tool-options { | |
| display: flex; | |
| gap: 6px; | |
| margin-bottom: 12px; | |
| } | |
| .tool-option-btn { | |
| flex: 1; | |
| padding: 6px; | |
| font-size: 9px; | |
| } | |
| .tool-option-btn.active { | |
| background: var(--accent); | |
| color: #fff; | |
| border-color: var(--accent); | |
| } | |
| .shortcut-hint { | |
| position: absolute; | |
| right: 5px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 8px; | |
| color: var(--muted); | |
| font-family: var(--mono); | |
| } | |
| .status-text { | |
| font-size: 9px; | |
| color: var(--muted); | |
| margin-top: 5px; | |
| min-height: 12px; | |
| } | |
| .image-info { | |
| display: flex; | |
| gap: 10px; | |
| font-size: 9px; | |
| color: var(--muted); | |
| margin-bottom: 10px; | |
| padding: 6px; | |
| background: var(--panel2); | |
| border-radius: var(--radius); | |
| } | |
| .info-item { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .info-item span:first-child { | |
| color: var(--text); | |
| font-weight: 500; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- HEADER --> | |
| <header> | |
| <div class="logo">perchance <span>//</span> img.edit</div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link">Built with anycoder</a> | |
| <div class="header-actions"> | |
| <button onclick="undo()" title="Undo (Ctrl+Z)"><i class="ph ph-arrow-u-up-left"></i> Undo</button> | |
| <button onclick="redo()" title="Redo (Ctrl+Y)"><i class="ph ph-arrow-u-up-right"></i> Redo</button> | |
| <button class="btn-primary" onclick="exportImage()" title="Export"><i class="ph ph-download"></i> Export</button> | |
| </div> | |
| </header> | |
| <!-- WORKSPACE --> | |
| <div class="workspace"> | |
| <!-- LEFT PANEL --> | |
| <div class="panel-left"> | |
| <div class="label-caps">Tools</div> | |
| <div class="tool-grid"> | |
| <button class="tool-btn active" onclick="setTool('move')" title="Pan/Select"> | |
| <i class="ph ph-cursor"></i> Move | |
| </button> | |
| <button class="tool-btn" onclick="setTool('draw')" title="Brush"> | |
| <i class="ph ph-paint-brush"></i> Draw | |
| </button> | |
| <button class="tool-btn" onclick="setTool('erase')" title="Eraser"> | |
| <i class="ph ph-eraser"></i> Erase | |
| </button> | |
| <button class="tool-btn" onclick="setTool('text')" title="Add Text"> | |
| <i class="ph ph-text-t"></i> Text | |
| </button> | |
| <button class="tool-btn" onclick="setTool('rect')" title="Rectangle"> | |
| <i class="ph ph-rectangle"></i> Rect | |
| </button> | |
| <button class="tool-btn" onclick="setTool('circle')" title="Circle"> | |
| <i class="ph ph-circle"></i> Circle | |
| </button> | |
| <button class="tool-btn" onclick="setTool('line')" title="Line"> | |
| <i class="ph ph-line-segment"></i> Line | |
| </button> | |
| <button class="tool-btn" onclick="setTool('fill')" title="Fill"> | |
| <i class="ph ph-drop"></i> Fill | |
| </button> | |
| </div> | |
| <div class="label-caps">Brush Settings</div> | |
| <div class="brush-preview"> | |
| <canvas id="brushPreview" width="30" height="30"></canvas> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Size</span><span class="fval" id="bSizeVal">10px</span></div> | |
| <input type="range" id="brushSize" min="1" max="100" value="10" oninput="updateBrush()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Hardness</span><span class="fval" id="bHardVal">100%</span></div> | |
| <input type="range" id="brushHardness" min="0" max="100" value="100" oninput="updateBrush()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Opacity</span><span class="fval" id="bOpVal">100%</span></div> | |
| <input type="range" id="brushOpacity" min="1" max="100" value="100" oninput="updateBrush()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Color</span></div> | |
| <input type="color" id="brushColor" value="#ffffff" oninput="updateBrush()"> | |
| </div> | |
| <div class="color-presets"> | |
| <div class="color-preset" style="background:#fff" onclick="setColor('#ffffff')"></div> | |
| <div class="color-preset" style="background:#000" onclick="setColor('#000000')"></div> | |
| <div class="color-preset" style="background:#f76f6f" onclick="setColor('#f76f6f')"></div> | |
| <div class="color-preset" style="background:#f7a26f" onclick="setColor('#f7a26f')"></div> | |
| <div class="color-preset" style="background:#5de8a0" onclick="setColor('#5de8a0')"></div> | |
| <div class="color-preset" style="background:#7c6ff7" onclick="setColor('#7c6ff7')"></div> | |
| </div> | |
| <div class="label-caps">Text Settings</div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Font Size</span><span class="fval" id="fontSizeVal">24px</span></div> | |
| <input type="range" id="fontSize" min="10" max="200" value="24" oninput="updateTextSettings()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Font</span></div> | |
| <select id="fontFamily" onchange="updateTextSettings()"> | |
| <option value="DM Mono">DM Mono</option> | |
| <option value="Syne">Syne</option> | |
| <option value="Arial">Arial</option> | |
| <option value="Times New Roman">Times New Roman</option> | |
| <option value="Courier New">Courier New</option> | |
| </select> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Style</span></div> | |
| <select id="fontStyle" onchange="updateTextSettings()"> | |
| <option value="normal">Normal</option> | |
| <option value="bold">Bold</option> | |
| <option value="italic">Italic</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- CANVAS --> | |
| <div class="canvas-area" id="canvasArea" oncontextmenu="return false;"> | |
| <div class="drop-zone" id="dropZone"> | |
| <div class="dz-ring"><i class="ph ph-image"></i></div> | |
| <div style="font-family:var(--display);font-weight:700;">Drop Image</div> | |
| <button class="btn-primary" onclick="document.getElementById('fileInput').click()">Browse Files</button> | |
| </div> | |
| <div class="canvas-centerer"> | |
| <div class="canvas-wrap" id="canvasWrap"> | |
| <canvas id="mainCanvas"></canvas> | |
| </div> | |
| </div> | |
| <div class="zoom-bar" id="zoomBar"> | |
| <button onclick="zoomOut()">-</button> | |
| <span id="zoomLabel">100%</span> | |
| <button onclick="zoomIn()">+</button> | |
| <button onclick="fitCanvas()">Fit</button> | |
| <button onclick="zoom1()">1:1</button> | |
| </div> | |
| </div> | |
| <!-- RIGHT PANEL --> | |
| <div class="panel-right"> | |
| <div class="tab-row"> | |
| <button class="tab active" onclick="switchTab('layers')">Layers</button> | |
| <button class="tab" onclick="switchTab('adjust')">Adjust</button> | |
| <button class="tab" onclick="switchTab('upscale')">Upscale</button> | |
| <button class="tab" onclick="switchTab('bg')">Remove BG</button> | |
| <button class="tab" onclick="switchTab('crop')">Crop</button> | |
| </div> | |
| <!-- Layers Tab --> | |
| <div class="tab-panel active" id="tab-layers"> | |
| <div class="image-info" id="imageInfo" style="display:none;"> | |
| <div class="info-item"> | |
| <span>Width</span> | |
| <span id="imgWidth">0px</span> | |
| </div> | |
| <div class="info-item"> | |
| <span>Height</span> | |
| <span id="imgHeight">0px</span> | |
| </div> | |
| </div> | |
| <div class="label-caps">Layer Stack</div> | |
| <div style="margin-bottom:10px;display:flex;gap:6px;"> | |
| <button style="flex:1" onclick="addNewLayer()">+ New Layer</button> | |
| <button onclick="duplicateLayer()"><i class="ph ph-copy"></i></button> | |
| </div> | |
| <div class="layer-list" id="layerList"></div> | |
| </div> | |
| <!-- Adjust Tab --> | |
| <div class="tab-panel" id="tab-adjust"> | |
| <div class="label-caps">Filters</div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Brightness</span><span class="fval" id="val-brightness">0</span></div> | |
| <input type="range" id="brightness" min="-100" max="100" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Contrast</span><span class="fval" id="val-contrast">0</span></div> | |
| <input type="range" id="contrast" min="-100" max="100" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Saturation</span><span class="fval" id="val-saturation">0</span></div> | |
| <input type="range" id="saturation" min="-100" max="100" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Blur</span><span class="fval" id="val-blur">0px</span></div> | |
| <input type="range" id="blur" min="0" max="20" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Hue Rotate</span><span class="fval" id="val-hue">0deg</span></div> | |
| <input type="range" id="hue" min="-180" max="180" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Invert</span><span class="fval" id="val-invert">0%</span></div> | |
| <input type="range" id="invert" min="0" max="100" value="0" oninput="applyFilters()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Presets</span></div> | |
| <select onchange="applyPreset(this.value)"> | |
| <option value="none">None</option> | |
| <option value="warm">Warm Tone</option> | |
| <option value="cool">Cool Tone</option> | |
| <option value="bw">Black & White</option> | |
| <option value="vintage">Vintage</option> | |
| <option value="sepia">Sepia</option> | |
| <option value="vibrant">Vibrant</option> | |
| <option value="dramatic">Dramatic</option> | |
| </select> | |
| </div> | |
| <button class="btn" style="width:100%" onclick="resetFilters()">Reset All</button> | |
| </div> | |
| <!-- Upscale Tab --> | |
| <div class="tab-panel" id="tab-upscale"> | |
| <div class="label-caps">AI Upscale Pipeline</div> | |
| <div class="ai-card"> | |
| <p>Select a model from the pipeline. Models are cached per session.</p> | |
| <div class="model-info" id="upscaleInfoText">Select a model below...</div> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Model Selection</span></div> | |
| <select id="upscaleModel" onchange="updateUpscaleInfo()"> | |
| <option value="bicubic">Bicubic (Fast, No AI)</option> | |
| <option value="realesrgan_x4plus">Real-ESRGAN ×4 Plus</option> | |
| <option value="realesrgan_general_x4">Real-ESR General ×4</option> | |
| <option value="realesrgan_x2plus">Real-ESRGAN ×2 Plus</option> | |
| <option value="bsrgan_x2">BSRGAN ×2</option> | |
| <option value="swinir_bsrgan_x4">SwinIR BSRGAN ×4</option> | |
| <option value="swin2sr_classical_x4">Swin2SR Classical ×4</option> | |
| <option value="swin2sr_classical_x2">Swin2SR Classical ×2</option> | |
| <option value="swinir_noise">SwinIR Noise Reduction</option> | |
| <option value="ultrasharp_x4">UltraSharp ×4</option> | |
| <option value="ultramix_smooth_x4">UltraMix Smooth ×4</option> | |
| </select> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Scale Factor</span><span class="fval" id="scaleVal">2x</span></div> | |
| <input type="range" id="upscaleScale" min="2" max="4" step="1" value="2" oninput="getEl('scaleVal').textContent=this.value+'x'"> | |
| </div> | |
| <button class="btn-primary" style="width:100%" onclick="runUpscale()">Run Upscale</button> | |
| <div id="upscaleStatus" class="status-text"></div> | |
| <div class="progress-bar" id="upscaleProgress" style="display:none;"> | |
| <div class="progress-fill" id="upscaleProgressFill"></div> | |
| </div> | |
| </div> | |
| <!-- Remove BG Tab --> | |
| <div class="tab-panel" id="tab-bg"> | |
| <div class="label-caps">Background Removal</div> | |
| <div class="ai-card"> | |
| <p>Uses @imgly/background-removal. Runs locally in browser.</p> | |
| </div> | |
| <button class="btn-primary" style="width:100%" onclick="removeBackground()">Remove Background</button> | |
| <div id="bgStatus" class="status-text"></div> | |
| <div class="progress-bar" id="bgProgress" style="display:none;"> | |
| <div class="progress-fill" id="bgProgressFill"></div> | |
| </div> | |
| </div> | |
| <!-- Crop Tab --> | |
| <div class="tab-panel" id="tab-crop"> | |
| <div class="label-caps">Crop & Transform</div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Width</span><span class="fval" id="cropW">0</span></div> | |
| <input type="range" id="cropWidth" min="1" max="100" value="100" oninput="updateCropPreview()"> | |
| </div> | |
| <div class="frow"> | |
| <div class="flabel"><span>Height</span><span class="fval" id="cropH">0</span></div> | |
| <input type="range" id="cropHeight" min="1" max="100" value="100" oninput="updateCropPreview()"> | |
| </div> | |
| <div class="tool-options"> | |
| <button class="tool-option-btn active" onclick="setCropAnchor('tl')">TL</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('tc')">TC</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('tr')">TR</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('ml')">ML</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('mc')">MC</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('mr')">MR</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('bl')">BL</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('bc')">BC</button> | |
| <button class="tool-option-btn" onclick="setCropAnchor('br')">BR</button> | |
| </div> | |
| <button class="btn-primary" style="width:100%" onclick="applyCrop()">Apply Crop</button> | |
| <button style="width:100%;margin-top:8px;" onclick="rotateCanvas(-90)">Rotate -90°</button> | |
| <button style="width:100%;margin-top:4px;" onclick="rotateCanvas(90)">Rotate +90°</button> | |
| <button style="width:100%;margin-top:4px;" onclick="flipHorizontal()">Flip Horizontal</button> | |
| <button style="width:100%;margin-top:4px;" onclick="flipVertical()">Flip Vertical</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- MODALS --> | |
| <div class="modal" id="textModal"> | |
| <div class="modal-content"> | |
| <h3>Add Text</h3> | |
| <input type="text" id="textInput" placeholder="Enter text..."> | |
| <button class="btn-primary" onclick="addTextToCanvas()">Add Text</button> | |
| <button style="margin-top:5px;" onclick="closeModal('textModal'); setTool('move')">Cancel</button> | |
| </div> | |
| </div> | |
| <input type="file" id="fileInput" accept="image/*" style="display:none" onchange="loadImage(this.files[0])"> | |
| <div id="toast">Message</div> | |
| <script> | |
| // --- UTILS --- | |
| const getEl = id => document.getElementById(id); | |
| const toast = (msg, duration = 3000) => { | |
| const t = getEl('toast'); | |
| t.textContent = msg; | |
| t.classList.add('on'); | |
| setTimeout(() => t.classList.remove('on'), duration); | |
| }; | |
| // --- STATE --- | |
| let layers = []; | |
| let activeLayerId = null; | |
| let zoomLevel = 1; | |
| let panStart = null; | |
| let panScroll = null; | |
| let isPanning = false; | |
| let currentTool = 'move'; | |
| let isDrawing = false; | |
| let drawingLayer = null; | |
| let shapeStart = null; | |
| let lastPoint = null; | |
| let cropAnchor = 'tl'; | |
| // --- HISTORY --- | |
| const history = { | |
| stack: [], | |
| pointer: -1, | |
| maxSize: 50, | |
| push: function(state) { | |
| if (this.pointer < this.stack.length - 1) { | |
| this.stack = this.stack.slice(0, this.pointer + 1); | |
| } | |
| this.stack.push(JSON.stringify(state)); | |
| if (this.stack.length > this.maxSize) { | |
| this.stack.shift(); | |
| } | |
| this.pointer = this.stack.length - 1; | |
| }, | |
| undo: function() { | |
| if (this.pointer > 0) { | |
| this.pointer--; | |
| restoreState(JSON.parse(this.stack[this.pointer])); | |
| toast('Undo'); | |
| } | |
| }, | |
| redo: function() { | |
| if (this.pointer < this.stack.length - 1) { | |
| this.pointer++; | |
| restoreState(JSON.parse(this.stack[this.pointer])); | |
| toast('Redo'); | |
| } | |
| } | |
| }; | |
| function saveState() { | |
| const state = layers.map(l => ({ | |
| id: l.id, | |
| type: l.type, | |
| name: l.name, | |
| data: l.canvas.toDataURL('image/png'), | |
| x: l.x, | |
| y: l.y, | |
| visible: l.visible, | |
| opacity: l.opacity | |
| })); | |
| history.push(state); | |
| } | |
| function restoreState(state) { | |
| const loadPromises = state.map(l => { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = img.width; | |
| canvas.height = img.height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0); | |
| resolve({ | |
| id: l.id, | |
| type: l.type, | |
| name: l.name, | |
| canvas: canvas, | |
| x: l.x, | |
| y: l.y, | |
| visible: l.visible, | |
| opacity: l.opacity | |
| }); | |
| }; | |
| img.src = l.data; | |
| }); | |
| }); | |
| Promise.all(loadPromises).then(loadedLayers => { | |
| layers = loadedLayers; | |
| activeLayerId = layers[0]?.id || null; | |
| renderLayersList(); | |
| renderLayers(); | |
| updateImageInfo(); | |
| }); | |
| } | |
| // --- INIT --- | |
| function init() { | |
| getEl('canvasArea').addEventListener('contextmenu', e => e.preventDefault()); | |
| // Pan & Draw handlers | |
| getEl('canvasArea').addEventListener('mousedown', handleMouseDown); | |
| getEl('canvasArea').addEventListener('mousemove', handleMouseMove); | |
| getEl('canvasArea').addEventListener('mouseup', handleMouseUp); | |
| getEl('canvasArea').addEventListener('mouseleave', handleMouseUp); | |
| // Wheel zoom | |
| getEl('canvasArea').addEventListener('wheel', handleWheel); | |
| // Drop zone | |
| window.addEventListener('dragover', e => e.preventDefault()); | |
| window.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]); | |
| }); | |
| // Keyboard | |
| window.addEventListener('keydown', handleKeyDown); | |
| updateBrush(); | |
| updateUpscaleInfo(); | |
| updateTextSettings(); | |
| } | |
| function handleMouseDown(e) { | |
| if (e.button === 1 || (currentTool === 'move' && e.button === 0)) { | |
| e.preventDefault(); | |
| isPanning = true; | |
| panStart = { x: e.clientX, y: e.clientY }; | |
| panScroll = { x: getEl('canvasArea').scrollLeft, y: getEl('canvasArea').scrollTop }; | |
| getEl('canvasArea').style.cursor = 'grabbing'; | |
| } else if (e.button === 0 && activeLayerId) { | |
| if (currentTool === 'draw' || currentTool === 'erase') { | |
| startDrawing(e); | |
| } else if (currentTool === 'rect' || currentTool === 'circle' || currentTool === 'line') { | |
| startShape(e); | |
| } else if (currentTool === 'fill') { | |
| floodFill(e); | |
| } else if (currentTool === 'text') { | |
| showTextModal(e); | |
| } | |
| } | |
| } | |
| function handleMouseMove(e) { | |
| if (isPanning && panStart && panScroll) { | |
| getEl('canvasArea').scrollLeft = panScroll.x - (e.clientX - panStart.x); | |
| getEl('canvasArea').scrollTop = panScroll.y - (e.clientY - panStart.y); | |
| } | |
| if (isDrawing && drawingLayer) { | |
| const pt = getMousePos(drawingLayer.canvas, e); | |
| const ctx = drawingLayer.canvas.getContext('2d'); | |
| if (currentTool === 'draw') { | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.strokeStyle = getEl('brushColor').value; | |
| ctx.lineWidth = getEl('brushSize').value; | |
| ctx.globalAlpha = getEl('brushOpacity').value / 100; | |
| if (lastPoint) { | |
| ctx.beginPath(); | |
| ctx.moveTo(lastPoint.x, lastPoint.y); | |
| ctx.lineTo(pt.x, pt.y); | |
| ctx.stroke(); | |
| } | |
| lastPoint = pt; | |
| } else if (currentTool === 'erase') { | |
| ctx.globalCompositeOperation = 'destination-out'; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineWidth = getEl('brushSize').value; | |
| if (lastPoint) { | |
| ctx.beginPath(); | |
| ctx.moveTo(lastPoint.x, lastPoint.y); | |
| ctx.lineTo(pt.x, pt.y); | |
| ctx.stroke(); | |
| } | |
| lastPoint = pt; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| renderLayers(); | |
| } | |
| } | |
| function handleMouseUp() { | |
| if (isPanning) { | |
| isPanning = false; | |
| panStart = null; | |
| panScroll = null; | |
| get |