@spark-apps/piclet 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,998 +1,1529 @@
1
- <!DOCTYPE html>
2
- <html data-piclet data-width="540" data-height="960">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>PicLet</title>
7
- <link rel="stylesheet" href="/css/theme.css">
8
- <script src="/js/piclet.js"></script>
9
- <style>
10
- .app{display:flex;flex-direction:column;height:100%}
11
- .main{display:flex;flex:1;gap:12px;overflow:hidden}
12
-
13
- /* Left panel - Preview */
14
- .preview-panel{flex:1;min-width:200px;display:flex;flex-direction:column;gap:8px}
15
- .preview-area{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg2);border-radius:6px;overflow:auto;position:relative;min-height:180px;transition:border-color .2s}
16
- .preview-area::before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/16px 16px;z-index:0}
17
- .preview-area.dragover{border:2px dashed var(--acc);background:rgba(255,204,0,0.05)}
18
- .preview-area img{max-width:100%;max-height:100%;object-fit:contain;position:relative;z-index:1;transform-origin:center;cursor:grab}
19
- .preview-area img.panning{cursor:grabbing}
20
- .preview-area .placeholder{position:relative;z-index:1;font-size:11px;color:var(--txt3);text-align:center;padding:12px}
21
- .preview-area .mini-sp{width:20px;height:20px;border:2px solid var(--bg3);border-top-color:var(--acc);border-radius:50%;animation:s .5s linear infinite;position:relative;z-index:1}
22
- .preview-info{font-size:10px;color:var(--txt3);text-align:center}
23
- .preview-zoom{display:flex;align-items:center;justify-content:center;gap:4px}
24
- .preview-zoom button{width:22px;height:22px;padding:0;font-size:14px;background:var(--bg2);border:1px solid var(--brd);border-radius:4px;color:var(--txt2);cursor:pointer;display:flex;align-items:center;justify-content:center}
25
- .preview-zoom button:hover{border-color:var(--acc);color:var(--acc)}
26
- .preview-zoom span{font-size:10px;color:var(--txt3);min-width:40px;text-align:center}
27
- .load-btn{width:100%;padding:8px;font-size:11px;background:var(--bg2);border:1px dashed var(--brd);border-radius:6px;color:var(--txt3);cursor:pointer;transition:all .2s}
28
- .load-btn:hover{border-color:var(--acc);color:var(--acc)}
29
-
30
- /* Resizable divider */
31
- .divider{width:6px;cursor:col-resize;background:transparent;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin:0 -2px}
32
- .divider:hover,.divider.dragging{background:var(--bg3)}
33
- .divider::after{content:'';width:2px;height:40px;background:var(--brd);border-radius:1px;transition:background .2s}
34
- .divider:hover::after,.divider.dragging::after{background:var(--acc)}
35
-
36
- /* Right panel - Tools */
37
- .tools-panel{width:300px;flex-shrink:0;display:flex;flex-direction:column;gap:6px;overflow-y:auto;padding-right:4px;align-self:flex-start;max-height:100%}
38
-
39
- /* Tool sections */
40
- .tool{background:var(--bg2);border-radius:6px;border:1px solid var(--brd);overflow:hidden}
41
- .tool.disabled{opacity:0.35;pointer-events:none}
42
- .tool.active{border-color:var(--acc)}
43
- .tool-header{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
44
- .tool-header:hover{background:rgba(255,255,255,0.02)}
45
- .tool-header img{width:18px;height:18px;opacity:0.7}
46
- .tool.active .tool-header img{opacity:1}
47
- .tool-header .name{flex:1;font-size:12px;color:var(--txt2)}
48
- .tool.active .tool-header .name{color:var(--txt);font-weight:500}
49
- .tool-header input[type="checkbox"]{display:none}
50
- .tool-header .toggle{width:16px;height:16px;border:1.5px solid var(--brd);border-radius:4px;display:flex;align-items:center;justify-content:center}
51
- .tool-header .toggle svg{width:10px;height:10px;fill:none;stroke:var(--acc);stroke-width:2.5;opacity:0}
52
- .tool.active .tool-header .toggle{border-color:var(--acc);background:var(--acc)}
53
- .tool.active .tool-header .toggle svg{opacity:1;stroke:#000}
54
- .tool-body{display:none;padding:6px 10px 10px;border-top:1px solid var(--brd)}
55
- .tool.active .tool-body{display:block}
56
- #t-removebg .tool-body{min-height:70px}
57
- #t-scale .tool-body{min-height:95px}
58
- #t-icons .tool-body{min-height:85px}
59
- #t-storepack .tool-body{min-height:170px}
60
-
61
- /* Tool options */
62
- .tool-opts{display:flex;flex-direction:column;gap:6px}
63
- .tool-row{display:flex;align-items:center;gap:8px}
64
- .tool-row label{font-size:10px;color:var(--txt3);width:55px;flex-shrink:0}
65
- .tool-row input[type="number"]{width:45px;padding:4px 6px;font-size:11px}
66
- .tool-row input[type="range"]{flex:1;height:4px}
67
- .tool-row .val{width:45px;font-size:10px;color:var(--acc2);text-align:right}
68
- .tool-row select{flex:1;padding:6px 8px;font-size:11px;background:var(--bg3);color:var(--txt);border:1px solid var(--brd);border-radius:4px;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 4l3 4 3-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 6px center;padding-right:24px}
69
- .tool-row select:hover{border-color:var(--acc)}
70
- .tool-row select:focus{outline:none;border-color:var(--acc)}
71
- .tool-row select option{background:var(--bg2);color:var(--txt);padding:8px}
72
- .tool-opts .opt{font-size:10px;padding:2px 0}
73
- .tool-opts .opt .box{width:12px;height:12px}
74
-
75
- .platforms{display:flex;gap:4px}
76
- .platforms .opt{flex:1;padding:6px 8px;background:var(--bg3);border-radius:4px;font-size:10px;white-space:nowrap}
77
- .platform-info{display:none}
78
-
79
- /* Dimension list */
80
- .dim-list{display:flex;flex-wrap:wrap;gap:4px;max-height:80px;overflow-y:auto;margin-bottom:6px}
81
- .dim-item{display:flex;align-items:center;gap:4px;padding:3px 6px;background:var(--bg3);border-radius:4px;font-size:10px;color:var(--txt2)}
82
- .dim-item .dim-x{color:var(--txt3);font-size:9px}
83
- .dim-item .dim-rm{width:12px;height:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--txt3);border-radius:2px;margin-left:2px}
84
- .dim-item .dim-rm:hover{background:rgba(255,100,100,0.2);color:#f66}
85
- .dim-add{display:flex;align-items:center;gap:4px;margin-bottom:8px}
86
- .dim-add input{width:50px;padding:4px 6px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt);-moz-appearance:textfield}
87
- .dim-add input::-webkit-outer-spin-button,.dim-add input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
88
- .dim-add span{color:var(--txt3);font-size:10px}
89
- .dim-add-btn{width:22px;height:22px;padding:0;font-size:14px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:bold}
90
- .dim-add-btn:hover{background:var(--acc2)}
91
- .preset-actions{display:flex;gap:4px}
92
- .preset-actions input{flex:1;padding:5px 8px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt)}
93
- .btn-sm{padding:5px 10px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt);cursor:pointer}
94
- .btn-sm:hover{border-color:var(--acc);color:var(--acc)}
95
- .btn-sm.btn-del{color:#a66}
96
- .btn-sm.btn-del:hover{border-color:#c44;color:#c44}
97
-
98
- /* Bottom bar */
99
- .bottom-bar{display:flex;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--brd)}
100
- .bottom-bar .btn{flex:1}
101
- .apply-btn{background:var(--acc)!important;color:#000!important;font-weight:600}
102
- .apply-btn:disabled{opacity:0.5;cursor:not-allowed}
103
-
104
- /* Header branding */
105
- .hd{flex-direction:column;align-items:flex-start;gap:2px;padding-bottom:10px}
106
- .hd-top{display:flex;align-items:center;gap:8px;width:100%}
107
- .hd-brand{display:flex;flex-direction:column}
108
- .hd-title{display:flex;align-items:baseline;gap:6px}
109
- .hd-title b{font-size:18px;background:linear-gradient(135deg,#eab308 0%,#fbbf24 50%,#f59e0b 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.5px}
110
- .hd-subtitle{font-size:10px;color:var(--acc2);font-weight:600;text-transform:uppercase;letter-spacing:1px}
111
- .hd-desc{font-size:10px;color:var(--txt3);margin-top:2px}
112
- .hd-link{margin-left:auto;font-size:9px;color:var(--txt3);text-decoration:none;padding:4px 8px;background:var(--bg2);border:1px solid var(--brd);border-radius:4px;transition:all .2s}
113
- .hd-link:hover{color:var(--acc);border-color:var(--acc);background:rgba(234,179,8,0.05)}
114
- .hd-file{font-size:11px;color:var(--txt2);margin-top:4px;padding-top:6px;border-top:1px solid var(--brd);width:100%}
115
- .hd-file span{color:var(--txt3)}
116
-
117
- /* States */
118
- .main.hide{display:none}
119
- .bottom-bar.hide{display:none}
120
-
121
- /* Themed alert modal */
122
- .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100;opacity:0;pointer-events:none;transition:opacity .2s}
123
- .modal-overlay.on{opacity:1;pointer-events:auto}
124
- .modal{background:var(--bg2);border:1px solid var(--brd);border-radius:8px;padding:16px 20px;min-width:200px;max-width:300px;text-align:center}
125
- .modal-msg{font-size:12px;color:var(--txt);margin-bottom:12px}
126
- .modal-btns{display:flex;gap:8px;justify-content:center}
127
- .modal-btn{padding:6px 20px;font-size:11px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:500}
128
- .modal-btn:hover{background:var(--acc2)}
129
- .modal-btn.danger{background:#c44;color:#fff}
130
- .modal-btn.danger:hover{background:#a33}
131
- .modal-btn.secondary{background:var(--bg3);color:var(--txt);border:1px solid var(--brd)}
132
- .modal-btn.secondary:hover{border-color:var(--acc)}
133
- </style>
134
- </head>
135
- <body>
136
- <div class="app">
137
- <div class="hd">
138
- <div class="hd-top">
139
- <div class="hd-brand">
140
- <div class="hd-title">
141
- <b>PicLet</b>
142
- <span class="hd-subtitle">Edit. Scale. Ship.</span>
143
- </div>
144
- <span class="hd-desc">Lightweight image tools for content creators</span>
145
- </div>
146
- <a href="https://piclet.app" class="hd-link" onclick="PicLet.shell('https://piclet.app');return false">piclet.app</a>
147
- </div>
148
- <div class="hd-file"><span>File:</span> <span id="fileName">-</span></div>
149
- </div>
150
- <div class="meta">
151
- <div>Original<b id="origSize">-</b></div>
152
- <div>Output<b id="outSize">-</b></div>
153
- </div>
154
-
155
- <!-- Main content -->
156
- <div class="main" id="M">
157
- <!-- Preview panel -->
158
- <div class="preview-panel">
159
- <div class="preview-area" id="pA">
160
- <span class="placeholder">Enable tools to preview</span>
161
- </div>
162
- <div class="preview-info" id="pI"></div>
163
- <div class="preview-zoom">
164
- <button onclick="zoomOut()" title="Zoom out">−</button>
165
- <span id="zoomLevel">100%</span>
166
- <button onclick="zoomIn()" title="Zoom in">+</button>
167
- <button onclick="zoomReset()" title="Reset zoom" style="font-size:10px;width:28px">Fit</button>
168
- </div>
169
- <button class="load-btn" onclick="loadNewImage()">Load Different Image...</button>
170
- <input type="file" id="fileInput" accept=".png,.jpg,.jpeg,.gif,.bmp,.ico" style="display:none" onchange="handleFileSelect(event)">
171
- </div>
172
-
173
- <!-- Resizable divider -->
174
- <div class="divider" id="divider"></div>
175
-
176
- <!-- Tools panel -->
177
- <div class="tools-panel">
178
- <!-- Remove Background -->
179
- <div class="tool" id="t-removebg" data-tool="removebg" data-ext=".png,.jpg,.jpeg,.ico">
180
- <div class="tool-header" onclick="toggleTool('removebg')">
181
- <img src="/icons/removebg.ico" alt="">
182
- <span class="name">Remove Background</span>
183
- <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
184
- </div>
185
- <div class="tool-body">
186
- <div class="tool-opts">
187
- <div class="tool-row">
188
- <label>Tolerance</label>
189
- <input type="range" id="rb-fuzz" min="0" max="100" value="10" oninput="$('rb-fuzzV').textContent=this.value+'%'" onchange="schedulePreview()">
190
- <span class="val" id="rb-fuzzV">10%</span>
191
- </div>
192
- <label class="opt"><input type="checkbox" id="rb-trim" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Auto-trim edges</label>
193
- <label class="opt"><input type="checkbox" id="rb-edges" onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Border only (preserve inner)</label>
194
- </div>
195
- </div>
196
- </div>
197
-
198
- <!-- Scale Image -->
199
- <div class="tool" id="t-scale" data-tool="scale" data-ext=".png,.jpg,.jpeg,.gif,.bmp,.ico">
200
- <div class="tool-header" onclick="toggleTool('scale')">
201
- <img src="/icons/rescale.ico" alt="">
202
- <span class="name">Scale Image</span>
203
- <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
204
- </div>
205
- <div class="tool-body">
206
- <div class="tool-opts">
207
- <div class="tool-row">
208
- <label>Width</label>
209
- <input type="range" id="sc-w" min="16" max="512" value="512" oninput="scaleSlide('w')" onchange="schedulePreview()">
210
- <span class="val" id="sc-wV">512px</span>
211
- </div>
212
- <div class="tool-row" id="sc-hRow">
213
- <label>Height</label>
214
- <input type="range" id="sc-h" min="16" max="512" value="512" oninput="scaleSlide('h')" onchange="schedulePreview()">
215
- <span class="val" id="sc-hV">512px</span>
216
- </div>
217
- <label class="opt"><input type="checkbox" id="sc-lock" onchange="toggleRatioLock()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Lock aspect ratio</label>
218
- <label class="opt"><input type="checkbox" id="sc-sq" onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Square output (add padding)</label>
219
- </div>
220
- </div>
221
- </div>
222
-
223
- <!-- Generate Icons (combined ICO + Icon Pack) -->
224
- <div class="tool" id="t-icons" data-tool="icons" data-ext=".png,.jpg,.jpeg,.ico">
225
- <div class="tool-header" onclick="toggleTool('icons')">
226
- <img src="/icons/iconpack.ico" alt="">
227
- <span class="name">Generate Icons</span>
228
- <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
229
- </div>
230
- <div class="tool-body">
231
- <div class="tool-opts">
232
- <label class="opt"><input type="checkbox" id="ic-trim" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Auto-trim transparent edges</label>
233
- <label class="opt"><input type="checkbox" id="ic-sq" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Make square before generating</label>
234
- </div>
235
- <div style="font-size:9px;color:var(--txt3);margin:6px 0 4px;text-transform:uppercase">Output Formats</div>
236
- <div class="platforms">
237
- <label class="opt"><input type="checkbox" id="ic-ico" checked><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>ICO</label>
238
- <label class="opt"><input type="checkbox" id="ic-web"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Web</label>
239
- <label class="opt"><input type="checkbox" id="ic-android"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Android</label>
240
- <label class="opt"><input type="checkbox" id="ic-ios"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>iOS</label>
241
- </div>
242
- </div>
243
- </div>
244
-
245
- <!-- Generate Store Assets -->
246
- <div class="tool" id="t-storepack" data-tool="storepack" data-ext=".png,.jpg,.jpeg">
247
- <div class="tool-header" onclick="toggleTool('storepack')">
248
- <img src="/icons/storepack.ico" alt="">
249
- <span class="name">Generate Store Assets</span>
250
- <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
251
- </div>
252
- <div class="tool-body">
253
- <div class="tool-opts">
254
- <div class="tool-row">
255
- <label>Preset</label>
256
- <select id="sp-preset" onchange="onPresetChange()"></select>
257
- </div>
258
- <div class="tool-row">
259
- <label>Scale</label>
260
- <select id="sp-mode">
261
- <option value="fit">Fit (letterbox)</option>
262
- <option value="fill">Fill (crop)</option>
263
- <option value="stretch">Stretch</option>
264
- </select>
265
- </div>
266
- </div>
267
- <div style="font-size:9px;color:var(--txt3);margin:8px 0 4px;text-transform:uppercase">Output Sizes</div>
268
- <div class="dim-list" id="sp-dims"></div>
269
- <div class="dim-add">
270
- <input type="number" id="sp-newW" placeholder="W" min="1" max="9999">
271
- <span>×</span>
272
- <input type="number" id="sp-newH" placeholder="H" min="1" max="9999">
273
- <button class="dim-add-btn" onclick="addDimension()">+</button>
274
- </div>
275
- <div class="preset-actions">
276
- <input type="text" id="sp-name" placeholder="Preset name...">
277
- <button class="btn-sm" onclick="saveCurrentPreset()">Save</button>
278
- <button class="btn-sm btn-del" onclick="deleteCurrentPreset()">Delete</button>
279
- </div>
280
- </div>
281
- </div>
282
- </div>
283
- </div>
284
-
285
- <!-- Bottom bar -->
286
- <div class="bottom-bar" id="B">
287
- <button class="btn btn-g" onclick="PicLet.close()">Close</button>
288
- <button class="btn apply-btn" id="applyBtn" onclick="apply()" disabled>Apply</button>
289
- </div>
290
-
291
- <!-- Loading state -->
292
- <div class="ld" id="L"><div class="sp"></div><span id="lT">Processing...</span></div>
293
- <!-- Log -->
294
- <div class="log" id="G"></div>
295
- <!-- Done state -->
296
- <div class="dn" id="D">
297
- <h4 id="dT"></h4>
298
- <p id="dM"></p>
299
- <div class="btns" style="width:100%;margin-top:8px">
300
- <button class="btn btn-g" onclick="reset()">Back</button>
301
- <button class="btn btn-p" onclick="PicLet.close()">Done</button>
302
- </div>
303
- </div>
304
- </div>
305
-
306
- <!-- Themed alert/confirm modal -->
307
- <div class="modal-overlay" id="modal" onclick="hideModal(event)">
308
- <div class="modal">
309
- <div class="modal-msg" id="modalMsg"></div>
310
- <div class="modal-btns" id="modalBtns">
311
- <button class="modal-btn" onclick="hideModal()">OK</button>
312
- </div>
313
- </div>
314
- </div>
315
-
316
- <script>
317
- const { $, log, fetchJson, postJson } = PicLet;
318
-
319
- // Themed alert/confirm
320
- let modalCallback = null;
321
-
322
- function showAlert(msg) {
323
- $('modalMsg').textContent = msg;
324
- $('modalBtns').innerHTML = '<button class="modal-btn" onclick="hideModal()">OK</button>';
325
- $('modal').classList.add('on');
326
- modalCallback = null;
327
- }
328
-
329
- function showConfirm(msg, onConfirm, dangerText = 'Delete') {
330
- $('modalMsg').textContent = msg;
331
- $('modalBtns').innerHTML = `
332
- <button class="modal-btn secondary" onclick="hideModal()">Cancel</button>
333
- <button class="modal-btn danger" onclick="confirmModal()">${dangerText}</button>
334
- `;
335
- $('modal').classList.add('on');
336
- modalCallback = onConfirm;
337
- }
338
-
339
- function hideModal(e) {
340
- if (!e || e.target === $('modal')) {
341
- $('modal').classList.remove('on');
342
- modalCallback = null;
343
- }
344
- }
345
-
346
- function confirmModal() {
347
- $('modal').classList.remove('on');
348
- if (modalCallback) modalCallback();
349
- modalCallback = null;
350
- }
351
- const pA = $('pA'), pI = $('pI');
352
-
353
- // Zoom and pan state
354
- let zoomScale = 1;
355
- const ZOOM_MIN = 0.25;
356
- const ZOOM_MAX = 4;
357
- const ZOOM_STEP = 0.25;
358
-
359
- function updateZoom() {
360
- const img = pA.querySelector('img');
361
- if (img) {
362
- img.style.transform = `scale(${zoomScale})`;
363
- // Remove max constraints when zoomed in
364
- if (zoomScale > 1) {
365
- img.style.maxWidth = 'none';
366
- img.style.maxHeight = 'none';
367
- } else {
368
- img.style.maxWidth = '100%';
369
- img.style.maxHeight = '100%';
370
- }
371
- }
372
- $('zoomLevel').textContent = Math.round(zoomScale * 100) + '%';
373
- }
374
-
375
- function zoomIn() {
376
- zoomScale = Math.min(ZOOM_MAX, zoomScale + ZOOM_STEP);
377
- updateZoom();
378
- }
379
-
380
- function zoomOut() {
381
- zoomScale = Math.max(ZOOM_MIN, zoomScale - ZOOM_STEP);
382
- updateZoom();
383
- }
384
-
385
- function zoomReset() {
386
- zoomScale = 1;
387
- pA.scrollLeft = 0;
388
- pA.scrollTop = 0;
389
- updateZoom();
390
- }
391
-
392
- // Mouse wheel zoom (no modifier key needed)
393
- pA.addEventListener('wheel', (e) => {
394
- e.preventDefault();
395
- if (e.deltaY < 0) zoomIn();
396
- else zoomOut();
397
- }, { passive: false });
398
-
399
- // Mouse drag panning
400
- let isPanning = false;
401
- let panStart = { x: 0, y: 0 };
402
- let scrollStart = { x: 0, y: 0 };
403
-
404
- pA.addEventListener('mousedown', (e) => {
405
- // Only pan on left click and if there's an image
406
- if (e.button !== 0 || !pA.querySelector('img')) return;
407
- isPanning = true;
408
- panStart = { x: e.clientX, y: e.clientY };
409
- scrollStart = { x: pA.scrollLeft, y: pA.scrollTop };
410
- const img = pA.querySelector('img');
411
- if (img) img.classList.add('panning');
412
- e.preventDefault();
413
- });
414
-
415
- document.addEventListener('mousemove', (e) => {
416
- if (!isPanning) return;
417
- const dx = e.clientX - panStart.x;
418
- const dy = e.clientY - panStart.y;
419
- pA.scrollLeft = scrollStart.x - dx;
420
- pA.scrollTop = scrollStart.y - dy;
421
- });
422
-
423
- document.addEventListener('mouseup', () => {
424
- if (isPanning) {
425
- isPanning = false;
426
- const img = pA.querySelector('img');
427
- if (img) img.classList.remove('panning');
428
- }
429
- });
430
-
431
- let imageInfo = {};
432
- let previewTimeout = null;
433
- let activeTools = new Set();
434
- let presets = [];
435
- let currentDimensions = []; // Editable dimensions for storepack
436
-
437
- // Toggle tool active state
438
- function toggleTool(tool) {
439
- const el = $('t-' + tool);
440
- if (!el || el.classList.contains('disabled')) return;
441
-
442
- if (activeTools.has(tool)) {
443
- activeTools.delete(tool);
444
- el.classList.remove('active');
445
- } else {
446
- activeTools.add(tool);
447
- el.classList.add('active');
448
- }
449
-
450
- updateApplyButton();
451
- schedulePreview();
452
- }
453
-
454
- // Update apply button state
455
- function updateApplyButton() {
456
- $('applyBtn').disabled = activeTools.size === 0;
457
- }
458
-
459
- // Filter tools by file extension
460
- function filterTools(ext) {
461
- document.querySelectorAll('.tool').forEach(el => {
462
- const exts = el.dataset.ext.split(',');
463
- el.classList.toggle('disabled', !exts.includes(ext));
464
- if (!exts.includes(ext)) {
465
- activeTools.delete(el.dataset.tool);
466
- el.classList.remove('active');
467
- }
468
- });
469
- }
470
-
471
- // Scale slider update with aspect ratio lock
472
- let aspectRatio = 1;
473
- let scaleDebounce = null;
474
-
475
- function scaleSlide(dim) {
476
- const locked = $('sc-lock').checked;
477
- const w = $('sc-w'), h = $('sc-h');
478
-
479
- if (locked) {
480
- // When locked, only width slider is used - calculate height from ratio
481
- const newW = +w.value;
482
- const newH = Math.round(newW / aspectRatio);
483
- h.value = Math.min(newH, +h.max);
484
- $('sc-wV').textContent = newW + 'px';
485
- $('sc-hV').textContent = h.value + 'px';
486
- } else {
487
- // Independent sliders
488
- $('sc-' + dim + 'V').textContent = $('sc-' + dim).value + 'px';
489
- }
490
-
491
- // Debounced preview update while sliding
492
- if (scaleDebounce) clearTimeout(scaleDebounce);
493
- scaleDebounce = setTimeout(schedulePreview, 150);
494
- }
495
-
496
- function toggleRatioLock() {
497
- const locked = $('sc-lock').checked;
498
- $('sc-hRow').style.display = locked ? 'none' : 'flex';
499
-
500
- if (locked) {
501
- // Recalculate height based on current width and aspect ratio
502
- const newH = Math.round(+$('sc-w').value / aspectRatio);
503
- $('sc-h').value = Math.min(newH, +$('sc-h').max);
504
- $('sc-hV').textContent = $('sc-h').value + 'px';
505
- }
506
- schedulePreview();
507
- }
508
-
509
- // Get combined options for all active tools
510
- function getOptions() {
511
- const opts = { tools: Array.from(activeTools) };
512
-
513
- if (activeTools.has('removebg')) {
514
- opts.removebg = {
515
- fuzz: +$('rb-fuzz').value,
516
- trim: $('rb-trim').checked,
517
- preserveInner: $('rb-edges').checked
518
- };
519
- }
520
-
521
- if (activeTools.has('scale')) {
522
- opts.scale = {
523
- width: +$('sc-w').value,
524
- height: +$('sc-h').value,
525
- makeSquare: $('sc-sq').checked
526
- };
527
- }
528
-
529
- if (activeTools.has('icons')) {
530
- opts.icons = {
531
- trim: $('ic-trim').checked,
532
- makeSquare: $('ic-sq').checked,
533
- ico: $('ic-ico').checked,
534
- web: $('ic-web').checked,
535
- android: $('ic-android').checked,
536
- ios: $('ic-ios').checked
537
- };
538
- }
539
-
540
- if (activeTools.has('storepack')) {
541
- const presetSel = $('sp-preset');
542
- const presetName = presetSel.value || $('sp-name').value.trim() || 'custom';
543
- opts.storepack = {
544
- dimensions: currentDimensions,
545
- scaleMode: $('sp-mode').value,
546
- presetName: presetName
547
- };
548
- }
549
-
550
- return opts;
551
- }
552
-
553
- // Schedule preview
554
- function schedulePreview() {
555
- if (previewTimeout) clearTimeout(previewTimeout);
556
- previewTimeout = setTimeout(generatePreview, 300);
557
- }
558
-
559
- // Show original image
560
- async function showOriginal() {
561
- pA.innerHTML = '<div class="mini-sp"></div>';
562
- try {
563
- const result = await postJson('/api/preview', { tools: [], original: true });
564
- if (result.success && result.imageData) {
565
- const img = document.createElement('img');
566
- img.src = result.imageData;
567
- pA.innerHTML = '';
568
- pA.appendChild(img);
569
- pI.textContent = `${imageInfo.width} × ${imageInfo.height}`;
570
- updateZoom(); // Apply current zoom
571
- }
572
- } catch (e) {
573
- pA.innerHTML = '<span class="placeholder">Failed to load image</span>';
574
- }
575
- }
576
-
577
- // Generate preview
578
- async function generatePreview() {
579
- if (activeTools.size === 0) {
580
- showOriginal();
581
- $('outSize').textContent = '-';
582
- return;
583
- }
584
-
585
- // Icon pack and store pack don't have meaningful previews (icons does though - shows trimmed/squared source)
586
- const previewableTools = ['removebg', 'scale', 'icons'];
587
- const hasPreviewable = Array.from(activeTools).some(t => previewableTools.includes(t));
588
-
589
- if (!hasPreviewable) {
590
- showOriginal();
591
- $('outSize').textContent = '(multiple files)';
592
- return;
593
- }
594
-
595
- pA.innerHTML = '<div class="mini-sp"></div>';
596
- pI.textContent = '';
597
-
598
- try {
599
- const result = await postJson('/api/preview', getOptions());
600
- if (result.success && result.imageData) {
601
- const img = document.createElement('img');
602
- img.src = result.imageData;
603
- pA.innerHTML = '';
604
- pA.appendChild(img);
605
- if (result.width && result.height) {
606
- pI.textContent = `${result.width} × ${result.height}`;
607
- $('outSize').textContent = `${result.width}×${result.height}`;
608
- }
609
- updateZoom(); // Apply current zoom
610
- } else {
611
- pA.innerHTML = '<span class="placeholder">' + (result.error || 'Preview failed') + '</span>';
612
- }
613
- } catch (e) {
614
- pA.innerHTML = '<span class="placeholder">Preview error</span>';
615
- }
616
- }
617
-
618
- // Load new image
619
- function loadNewImage() {
620
- $('fileInput').click();
621
- }
622
-
623
- async function handleFileSelect(e) {
624
- const file = e.target.files[0];
625
- if (!file) return;
626
- await loadImageFile(file);
627
- e.target.value = '';
628
- }
629
-
630
- // Load image file (shared by file input and drag/drop)
631
- async function loadImageFile(file) {
632
- const reader = new FileReader();
633
- reader.onload = async () => {
634
- try {
635
- const base64 = reader.result.split(',')[1]; // Remove data URL prefix
636
- const result = await postJson('/api/load', {
637
- fileName: file.name,
638
- data: base64,
639
- mimeType: file.type
640
- });
641
- if (result.success) {
642
- imageInfo = result;
643
- updateImageInfo();
644
- zoomReset(); // Reset zoom for new image
645
- showOriginal();
646
- } else {
647
- showAlert('Failed to load image: ' + (result.error || 'Unknown error'));
648
- }
649
- } catch (err) {
650
- showAlert('Failed to load image: ' + err.message);
651
- }
652
- };
653
- reader.readAsDataURL(file);
654
- }
655
-
656
- // Drag and drop handlers (only for external file drops)
657
- function setupDragDrop() {
658
- const area = pA;
659
-
660
- area.addEventListener('dragenter', (e) => {
661
- // Only respond to file drags from outside
662
- if (e.dataTransfer?.types?.includes('Files')) {
663
- e.preventDefault();
664
- area.classList.add('dragover');
665
- }
666
- });
667
-
668
- area.addEventListener('dragover', (e) => {
669
- if (e.dataTransfer?.types?.includes('Files')) {
670
- e.preventDefault();
671
- }
672
- });
673
-
674
- area.addEventListener('dragleave', (e) => {
675
- // Only remove if actually leaving the area
676
- if (!area.contains(e.relatedTarget)) {
677
- area.classList.remove('dragover');
678
- }
679
- });
680
-
681
- area.addEventListener('drop', (e) => {
682
- e.preventDefault();
683
- area.classList.remove('dragover');
684
-
685
- const files = e.dataTransfer?.files;
686
- if (files && files.length > 0) {
687
- const file = files[0];
688
- // Check if it's an image
689
- if (file.type.startsWith('image/') || /\.(png|jpg|jpeg|gif|bmp|ico)$/i.test(file.name)) {
690
- loadImageFile(file);
691
- }
692
- }
693
- });
694
- }
695
-
696
- // Update displayed image info
697
- function updateImageInfo() {
698
- $('fileName').textContent = imageInfo.fileName;
699
- $('origSize').textContent = imageInfo.width + '×' + imageInfo.height;
700
-
701
- const ext = '.' + imageInfo.fileName.split('.').pop().toLowerCase();
702
- filterTools(ext);
703
-
704
- // Calculate aspect ratio
705
- aspectRatio = imageInfo.width / imageInfo.height;
706
-
707
- // Set scale sliders max to image dimensions (can't upscale)
708
- $('sc-w').max = imageInfo.width;
709
- $('sc-h').max = imageInfo.height;
710
- $('sc-w').value = imageInfo.width;
711
- $('sc-h').value = imageInfo.height;
712
- $('sc-wV').textContent = imageInfo.width + 'px';
713
- $('sc-hV').textContent = imageInfo.height + 'px';
714
- }
715
-
716
- // ── Store Assets Preset Management ──
717
-
718
- // Handle preset selection change
719
- function onPresetChange() {
720
- const sel = $('sp-preset');
721
- const preset = presets.find(p => p.id === sel.value);
722
- console.log('Selected preset:', sel.value, preset);
723
- if (preset && preset.icons && preset.icons.length > 0) {
724
- currentDimensions = preset.icons.map(i => ({ width: i.width, height: i.height, filename: i.filename }));
725
- $('sp-name').value = preset.name;
726
- console.log('Loaded dimensions:', currentDimensions);
727
- } else {
728
- currentDimensions = [];
729
- $('sp-name').value = sel.value ? (preset?.name || '') : '';
730
- }
731
- // Hide delete button for unsaved custom presets
732
- document.querySelector('.btn-del').style.display = sel.value ? '' : 'none';
733
- renderDimensions();
734
- }
735
-
736
- // Render dimension chips
737
- function renderDimensions() {
738
- const container = $('sp-dims');
739
- container.innerHTML = currentDimensions.map((d, i) =>
740
- `<div class="dim-item"><span>${d.width}</span><span class="dim-x">×</span><span>${d.height}</span><span class="dim-rm" onclick="removeDimension(${i})">×</span></div>`
741
- ).join('');
742
- }
743
-
744
- // Add a new dimension
745
- function addDimension() {
746
- const w = +$('sp-newW').value;
747
- const h = +$('sp-newH').value;
748
- console.log('Adding dimension:', w, h);
749
- if (w > 0 && h > 0) {
750
- // Avoid duplicates
751
- if (!currentDimensions.some(d => d.width === w && d.height === h)) {
752
- currentDimensions.push({ width: w, height: h, filename: `${w}x${h}.png` });
753
- console.log('Current dimensions:', currentDimensions);
754
- renderDimensions();
755
- }
756
- $('sp-newW').value = '';
757
- $('sp-newH').value = '';
758
- } else {
759
- console.log('Invalid dimensions - w or h not > 0');
760
- }
761
- }
762
-
763
- // Remove a dimension
764
- function removeDimension(idx) {
765
- currentDimensions.splice(idx, 1);
766
- renderDimensions();
767
- }
768
-
769
- // Save current dimensions as preset
770
- async function saveCurrentPreset() {
771
- console.log('Saving preset, currentDimensions:', currentDimensions);
772
- const name = $('sp-name').value.trim();
773
- if (!name) {
774
- showAlert('Please enter a preset name');
775
- return;
776
- }
777
- if (currentDimensions.length === 0) {
778
- showAlert('Please add at least one dimension');
779
- return;
780
- }
781
-
782
- // Use selected preset ID if editing, otherwise generate from name
783
- const selectedId = $('sp-preset').value;
784
- const id = selectedId || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
785
- const preset = {
786
- id,
787
- name,
788
- description: 'Custom preset',
789
- icons: currentDimensions.map(d => ({
790
- filename: d.filename || `${d.width}x${d.height}.png`,
791
- width: d.width,
792
- height: d.height
793
- }))
794
- };
795
-
796
- const btn = document.querySelector('.preset-actions .btn-sm');
797
- btn.disabled = true;
798
- btn.textContent = 'Saving...';
799
-
800
- try {
801
- const result = await postJson('/api/save-preset', preset);
802
- if (result.success) {
803
- // Update presets list and select the new one
804
- const existing = presets.findIndex(p => p.id === id);
805
- if (existing >= 0) {
806
- presets[existing] = preset;
807
- } else {
808
- presets.push(preset);
809
- const opt = document.createElement('option');
810
- opt.value = id;
811
- opt.textContent = name;
812
- $('sp-preset').appendChild(opt);
813
- }
814
- $('sp-preset').value = id;
815
- // Show delete button now that preset is saved
816
- document.querySelector('.btn-del').style.display = '';
817
-
818
- // Show success feedback
819
- btn.textContent = 'Saved!';
820
- btn.style.borderColor = '#4c6';
821
- btn.style.color = '#4c6';
822
- setTimeout(() => {
823
- btn.textContent = 'Save Preset';
824
- btn.style.borderColor = '';
825
- btn.style.color = '';
826
- btn.disabled = false;
827
- }, 1500);
828
- } else {
829
- btn.textContent = 'Failed';
830
- btn.style.borderColor = '#f66';
831
- btn.style.color = '#f66';
832
- setTimeout(() => {
833
- btn.textContent = 'Save Preset';
834
- btn.style.borderColor = '';
835
- btn.style.color = '';
836
- btn.disabled = false;
837
- }, 1500);
838
- }
839
- } catch (e) {
840
- btn.textContent = 'Error';
841
- btn.style.borderColor = '#f66';
842
- btn.style.color = '#f66';
843
- setTimeout(() => {
844
- btn.textContent = 'Save Preset';
845
- btn.style.borderColor = '';
846
- btn.style.color = '';
847
- btn.disabled = false;
848
- }, 1500);
849
- }
850
- }
851
-
852
- // Delete current preset
853
- function deleteCurrentPreset() {
854
- const selectedId = $('sp-preset').value;
855
- if (!selectedId) {
856
- showAlert('No preset selected to delete');
857
- return;
858
- }
859
-
860
- const preset = presets.find(p => p.id === selectedId);
861
- const presetName = preset?.name || selectedId;
862
-
863
- showConfirm(`Delete preset "${presetName}"?\n\nThis cannot be undone.`, async () => {
864
- try {
865
- const result = await postJson('/api/delete-preset', { id: selectedId });
866
- if (result.success) {
867
- // Remove from local presets array
868
- const idx = presets.findIndex(p => p.id === selectedId);
869
- if (idx >= 0) presets.splice(idx, 1);
870
-
871
- // Remove from dropdown and reset
872
- const sel = $('sp-preset');
873
- const opt = sel.querySelector(`option[value="${selectedId}"]`);
874
- if (opt) opt.remove();
875
- sel.value = '';
876
- onPresetChange();
877
-
878
- showAlert('Preset deleted');
879
- } else {
880
- showAlert('Failed to delete: ' + (result.error || 'Unknown error'));
881
- }
882
- } catch (e) {
883
- showAlert('Failed to delete: ' + e.message);
884
- }
885
- });
886
- }
887
-
888
- // Apply all selected tools
889
- async function apply() {
890
- if (activeTools.size === 0) return;
891
-
892
- $('M').classList.add('hide');
893
- $('B').classList.add('hide');
894
- $('L').classList.add('on');
895
- $('G').classList.add('on');
896
- $('G').innerHTML = '';
897
-
898
- const toolNames = Array.from(activeTools).map(t => {
899
- const names = { removebg: 'Remove BG', scale: 'Scale', icons: 'Icons', storepack: 'Store Assets' };
900
- return names[t] || t;
901
- });
902
- $('lT').textContent = toolNames.join('') + '...';
903
-
904
- try {
905
- const result = await postJson('/api/process', getOptions());
906
-
907
- if (result.logs) {
908
- result.logs.forEach(l => log('G', l.type[0], l.message));
909
- }
910
-
911
- $('L').classList.remove('on');
912
- $('D').classList.add('on', result.success ? 'ok' : 'err');
913
- $('dT').textContent = result.success ? 'Done' : 'Failed';
914
- $('dM').textContent = result.success ? result.output : result.error;
915
- } catch (e) {
916
- log('G', 'e', e.message);
917
- $('L').classList.remove('on');
918
- $('D').classList.add('on', 'err');
919
- $('dT').textContent = 'Error';
920
- $('dM').textContent = e.message;
921
- }
922
- }
923
-
924
- // Reset to main view
925
- function reset() {
926
- $('D').classList.remove('on', 'ok', 'err');
927
- $('G').classList.remove('on');
928
- $('M').classList.remove('hide');
929
- $('B').classList.remove('hide');
930
- }
931
-
932
- // Init
933
- setupDragDrop();
934
- setupDivider();
935
-
936
- // Resizable divider
937
- function setupDivider() {
938
- const divider = $('divider');
939
- const toolsPanel = document.querySelector('.tools-panel');
940
- const main = $('M');
941
- let isDragging = false;
942
-
943
- divider.addEventListener('mousedown', (e) => {
944
- isDragging = true;
945
- divider.classList.add('dragging');
946
- document.body.style.cursor = 'col-resize';
947
- document.body.style.userSelect = 'none';
948
- e.preventDefault();
949
- });
950
-
951
- document.addEventListener('mousemove', (e) => {
952
- if (!isDragging) return;
953
- const mainRect = main.getBoundingClientRect();
954
- // Tools width = distance from cursor to right edge
955
- const toolsWidth = mainRect.right - e.clientX;
956
- // Clamp: min 240px for tools, leave at least 150px for preview
957
- const maxTools = mainRect.width - 150;
958
- const clampedWidth = Math.max(240, Math.min(maxTools, toolsWidth));
959
- toolsPanel.style.width = clampedWidth + 'px';
960
- });
961
-
962
- document.addEventListener('mouseup', () => {
963
- if (isDragging) {
964
- isDragging = false;
965
- divider.classList.remove('dragging');
966
- document.body.style.cursor = '';
967
- document.body.style.userSelect = '';
968
- }
969
- });
970
- }
971
-
972
- fetchJson('/api/info').then(d => {
973
- imageInfo = d;
974
- updateImageInfo();
975
-
976
- // Load presets for store pack (full preset data)
977
- const presetData = d.defaults?.presets || d.presets || [];
978
- console.log('Loaded presets:', presetData);
979
- if (presetData.length > 0) {
980
- presets = presetData;
981
- const sel = $('sp-preset');
982
- sel.innerHTML = '<option value="">Custom...</option>';
983
- presets.forEach(p => {
984
- const opt = document.createElement('option');
985
- opt.value = p.id;
986
- opt.textContent = p.name;
987
- sel.appendChild(opt);
988
- });
989
- }
990
- // Hide delete button initially (Custom... selected)
991
- document.querySelector('.btn-del').style.display = 'none';
992
-
993
- // Show original image immediately
994
- showOriginal();
995
- });
996
- </script>
997
- </body>
998
- </html>
1
+ <!DOCTYPE html>
2
+ <html data-piclet data-width="560" data-height="960">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>PicLet</title>
7
+ <link rel="stylesheet" href="/css/theme.css">
8
+ <script src="/js/piclet.js"></script>
9
+ <style>
10
+ .app{display:flex;flex-direction:column;height:100%}
11
+ .main{display:flex;flex:1;gap:12px;overflow:hidden;margin-top:10px}
12
+
13
+ /* Left panel - Preview */
14
+ .preview-panel{flex:1;min-width:200px;display:flex;flex-direction:column;gap:8px}
15
+ .preview-area{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg2);border-radius:6px;overflow:auto;position:relative;min-height:180px;transition:border-color .2s}
16
+ .preview-area::before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/16px 16px;z-index:0}
17
+ .preview-area.dragover{border:2px dashed var(--acc);background:rgba(255,204,0,0.05)}
18
+ .preview-area img{max-width:100%;max-height:100%;object-fit:contain;position:relative;z-index:1;transform-origin:center;cursor:grab}
19
+ .preview-area img.panning{cursor:grabbing}
20
+ .preview-area .placeholder{position:relative;z-index:1;font-size:11px;color:var(--txt3);text-align:center;padding:12px}
21
+ .preview-area .mini-sp{width:20px;height:20px;border:2px solid var(--bg3);border-top-color:var(--acc);border-radius:50%;animation:s .5s linear infinite;position:relative;z-index:1}
22
+ .preview-info{font-size:10px;color:var(--txt3);text-align:center}
23
+ .preview-zoom{display:flex;align-items:center;justify-content:center;gap:4px}
24
+ .preview-zoom button{width:22px;height:22px;padding:0;font-size:14px;background:var(--bg2);border:1px solid var(--brd);border-radius:4px;color:var(--txt2);cursor:pointer;display:flex;align-items:center;justify-content:center}
25
+ .preview-zoom button:hover{border-color:var(--acc);color:var(--acc)}
26
+ .preview-zoom span{font-size:10px;color:var(--txt3);min-width:40px;text-align:center}
27
+ .preview-zoom .zoom-sep{width:1px;height:16px;background:var(--brd);margin:0 6px;min-width:1px}
28
+ .play-controls{display:none;align-items:center;gap:4px}
29
+ .play-controls.show{display:flex}
30
+ .play-controls button{width:26px;font-size:10px}
31
+ .play-controls button.playing{background:var(--acc);border-color:var(--acc);color:#000}
32
+ .play-controls select{padding:3px 4px;font-size:10px;background:var(--bg2);color:var(--txt2);border:1px solid var(--brd);border-radius:4px;cursor:pointer}
33
+ .play-controls select:hover{border-color:var(--acc)}
34
+ .load-btn{width:100%;padding:8px;font-size:11px;background:var(--bg2);border:1px dashed var(--brd);border-radius:6px;color:var(--txt3);cursor:pointer;transition:all .2s}
35
+ .load-btn:hover{border-color:var(--acc);color:var(--acc)}
36
+
37
+ /* Resizable divider */
38
+ .divider{width:6px;cursor:col-resize;background:transparent;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin:0 -2px}
39
+ .divider:hover,.divider.dragging{background:var(--bg3)}
40
+ .divider::after{content:'';width:2px;height:40px;background:var(--brd);border-radius:1px;transition:background .2s}
41
+ .divider:hover::after,.divider.dragging::after{background:var(--acc)}
42
+
43
+ /* Right panel - Tools */
44
+ .tools-panel{width:300px;min-width:240px;flex-shrink:0;display:flex;flex-direction:column;gap:6px;overflow-y:auto;overflow-x:hidden;padding-right:4px;align-self:flex-start;max-height:100%;box-sizing:border-box}
45
+
46
+ /* Tool sections */
47
+ .tool{background:var(--bg2);border-radius:6px;border:1px solid var(--brd);overflow:hidden;min-width:0}
48
+ .tool.disabled{opacity:0.35;pointer-events:none}
49
+ .tool.active{border-color:var(--acc)}
50
+ .tool-header{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
51
+ .tool-header:hover{background:rgba(255,255,255,0.02)}
52
+ .tool-header img{width:18px;height:18px;opacity:0.7}
53
+ .tool.active .tool-header img{opacity:1}
54
+ .tool-header .name{flex:1;font-size:12px;color:var(--txt2)}
55
+ .tool.active .tool-header .name{color:var(--txt);font-weight:500}
56
+ .tool-header input[type="checkbox"]{display:none}
57
+ .tool-header .toggle{width:16px;height:16px;border:1.5px solid var(--brd);border-radius:4px;display:flex;align-items:center;justify-content:center}
58
+ .tool-header .toggle svg{width:10px;height:10px;fill:none;stroke:var(--acc);stroke-width:2.5;opacity:0}
59
+ .tool.active .tool-header .toggle{border-color:var(--acc);background:var(--acc)}
60
+ .tool.active .tool-header .toggle svg{opacity:1;stroke:#000}
61
+ .tool-body{display:none;padding:6px 10px 10px;border-top:1px solid var(--brd);min-width:0;overflow:hidden}
62
+ .tool.active .tool-body{display:block}
63
+ #t-removebg .tool-body{min-height:70px}
64
+ #t-scale .tool-body{min-height:95px}
65
+ #t-icons .tool-body{min-height:85px}
66
+ #t-storepack .tool-body{min-height:170px}
67
+
68
+ /* Tool options */
69
+ .tool-opts{display:flex;flex-direction:column;gap:6px}
70
+ .tool-row{display:flex;align-items:center;gap:8px}
71
+ .tool-row label{font-size:10px;color:var(--txt3);width:55px;flex-shrink:0}
72
+ .tool-row input[type="number"]{width:45px;padding:4px 6px;font-size:11px}
73
+ .tool-row input[type="range"]{flex:1;height:4px}
74
+ .tool-row .val{width:45px;font-size:10px;color:var(--acc2);text-align:right}
75
+ .tool-row select{flex:1;padding:6px 8px;font-size:11px;background:var(--bg3);color:var(--txt);border:1px solid var(--brd);border-radius:4px;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 4l3 4 3-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 6px center;padding-right:24px}
76
+ .tool-row select:hover{border-color:var(--acc)}
77
+ .tool-row select:focus{outline:none;border-color:var(--acc)}
78
+ .tool-row select option{background:var(--bg2);color:var(--txt);padding:8px}
79
+ .tool-opts .opt{font-size:10px;padding:2px 0}
80
+ .tool-opts .opt .box{width:12px;height:12px}
81
+
82
+ .platforms{display:flex;gap:4px}
83
+ .platforms .opt{flex:1;padding:6px 8px;background:var(--bg3);border-radius:4px;font-size:10px;white-space:nowrap}
84
+ .platform-info{display:none}
85
+
86
+ /* Dimension list */
87
+ .dim-list{display:flex;flex-wrap:wrap;gap:4px;max-height:80px;overflow-y:auto;margin-bottom:6px}
88
+ .dim-item{display:flex;align-items:center;gap:4px;padding:3px 6px;background:var(--bg3);border-radius:4px;font-size:10px;color:var(--txt2)}
89
+ .dim-item .dim-x{color:var(--txt3);font-size:9px}
90
+ .dim-item .dim-rm{width:12px;height:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--txt3);border-radius:2px;margin-left:2px}
91
+ .dim-item .dim-rm:hover{background:rgba(255,100,100,0.2);color:#f66}
92
+ .dim-add{display:flex;align-items:center;gap:4px;margin-bottom:8px}
93
+ .dim-add input{width:50px;padding:4px 6px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt);-moz-appearance:textfield}
94
+ .dim-add input::-webkit-outer-spin-button,.dim-add input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
95
+ .dim-add span{color:var(--txt3);font-size:10px}
96
+ .dim-add-btn{width:22px;height:22px;padding:0;font-size:14px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:bold}
97
+ .dim-add-btn:hover{background:var(--acc2)}
98
+ .preset-actions{display:flex;gap:4px}
99
+ .preset-actions input{flex:1;padding:5px 8px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt)}
100
+ .btn-sm{padding:5px 10px;font-size:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt);cursor:pointer}
101
+ .btn-sm:hover{border-color:var(--acc);color:var(--acc)}
102
+ .btn-sm.btn-del{color:#a66}
103
+ .btn-sm.btn-del:hover{border-color:#c44;color:#c44}
104
+
105
+ /* Bottom bar */
106
+ .bottom-bar{display:flex;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--brd)}
107
+ .bottom-bar .btn{flex:1}
108
+ .apply-btn{background:var(--acc)!important;color:#000!important;font-weight:600}
109
+ .apply-btn:disabled{opacity:0.5;cursor:not-allowed}
110
+
111
+ /* Header branding */
112
+ .hd{flex-direction:column;align-items:stretch;gap:0;padding-bottom:8px;border-bottom:1px solid var(--brd);position:relative}
113
+ .hd-top{display:flex;align-items:center;gap:10px;width:100%}
114
+ .hd-brand{display:flex;flex-direction:column;gap:2px}
115
+ .hd-title{display:flex;align-items:baseline;gap:8px}
116
+ .hd-title b{font-size:20px;color:#eab308;letter-spacing:-0.5px;font-weight:700}
117
+ .hd-subtitle{font-size:9px;color:var(--acc);font-weight:600;text-transform:uppercase;letter-spacing:1.5px;opacity:0.9}
118
+ .hd-desc{font-size:10px;color:var(--txt3);margin-top:1px}
119
+ .hd-links{margin-left:auto;display:flex;gap:6px;align-items:center}
120
+ .hd-link{font-size:9px;color:var(--acc);text-decoration:none;padding:5px 10px;background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.3);border-radius:4px;transition:all .2s;font-weight:500}
121
+ .hd-link:hover{color:#000;border-color:var(--acc);background:var(--acc)}
122
+ .hd-coffee{display:flex;align-items:center;gap:4px;font-size:9px;color:#ffdd00;text-decoration:none;padding:5px 8px;background:rgba(255,221,0,0.1);border:1px solid rgba(255,221,0,0.25);border-radius:4px;transition:all .2s;font-weight:500}
123
+ .hd-coffee:hover{color:#000;border-color:#ffdd00;background:#ffdd00}
124
+ .hd-coffee svg{width:12px;height:12px;fill:currentColor}
125
+ .hd-meta{display:flex;align-items:center;gap:12px;font-size:10px;color:var(--txt3);margin-top:8px;padding-top:8px;border-top:1px solid var(--brd);width:100%}
126
+ .hd-meta b{color:var(--txt2);font-weight:500;margin-left:3px}
127
+
128
+ /* States */
129
+ .main.hide{display:none}
130
+ .bottom-bar.hide{display:none}
131
+
132
+ /* Themed alert modal */
133
+ .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100;opacity:0;pointer-events:none;transition:opacity .2s}
134
+ .modal-overlay.on{opacity:1;pointer-events:auto}
135
+ .modal{background:var(--bg2);border:1px solid var(--brd);border-radius:8px;padding:16px 20px;min-width:200px;max-width:300px;text-align:center}
136
+ .modal-msg{font-size:12px;color:var(--txt);margin-bottom:12px}
137
+ .modal-btns{display:flex;gap:8px;justify-content:center}
138
+ .modal-btn{padding:6px 20px;font-size:11px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:500}
139
+ .modal-btn:hover{background:var(--acc2)}
140
+ .modal-btn.danger{background:#c44;color:#fff}
141
+ .modal-btn.danger:hover{background:#a33}
142
+ .modal-btn.secondary{background:var(--bg3);color:var(--txt);border:1px solid var(--brd)}
143
+ .modal-btn.secondary:hover{border-color:var(--acc)}
144
+
145
+ /* GIF Frame Strip - Left vertical panel */
146
+ .frame-panel{display:none;width:80px;min-width:60px;flex-shrink:0;flex-direction:column;gap:6px;background:var(--bg2);border-radius:6px;padding:8px;overflow:hidden}
147
+ .frame-panel.show{display:flex}
148
+ .frame-panel-header{font-size:9px;color:var(--txt3);text-align:center;padding-bottom:6px;border-bottom:1px solid var(--brd)}
149
+ .frame-panel-header b{color:var(--acc2);display:block;font-size:11px}
150
+ .frame-strip-scroll{display:flex;flex-direction:column;gap:4px;overflow-y:auto;flex:1;padding:4px 0;scrollbar-width:thin}
151
+ .frame-strip-scroll::-webkit-scrollbar{width:4px}
152
+ .frame-strip-scroll::-webkit-scrollbar-track{background:var(--bg3);border-radius:2px}
153
+ .frame-strip-scroll::-webkit-scrollbar-thumb{background:var(--brd);border-radius:2px}
154
+ .frame-strip-scroll::-webkit-scrollbar-thumb:hover{background:var(--txt3)}
155
+ .frame-thumb{flex-shrink:0;width:100%;aspect-ratio:1;border:2px solid var(--brd);border-radius:4px;overflow:hidden;cursor:pointer;position:relative;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/8px 8px}
156
+ .frame-thumb:hover{border-color:var(--txt3)}
157
+ .frame-thumb.selected{border-color:var(--acc)}
158
+ .frame-thumb img{width:100%;height:100%;object-fit:contain}
159
+ .frame-thumb .frame-num{position:absolute;bottom:1px;right:2px;font-size:8px;color:#fff;text-shadow:0 0 2px #000,0 0 2px #000}
160
+ .frame-thumb.processing::after{content:'';position:absolute;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}
161
+ .frame-thumb.processing::before{content:'';position:absolute;top:50%;left:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border:2px solid var(--bg3);border-top-color:var(--acc);border-radius:50%;animation:s .5s linear infinite;z-index:1}
162
+ .gif-export-btn{display:none;width:100%;padding:8px 4px;font-size:10px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:600;margin-top:6px}
163
+ .gif-export-btn.show{display:block}
164
+ .gif-export-btn:hover{background:var(--acc2)}
165
+
166
+ /* Export modal */
167
+ .export-modal{background:var(--bg2);border:1px solid var(--brd);border-radius:8px;padding:16px 20px;min-width:280px;max-width:320px}
168
+ .export-modal h3{font-size:14px;color:var(--txt);margin:0 0 12px;font-weight:600}
169
+ .export-options{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
170
+ .export-opt{display:flex;align-items:flex-start;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--brd);border-radius:6px;cursor:pointer;transition:all .2s}
171
+ .export-opt:hover{border-color:var(--txt3)}
172
+ .export-opt.selected{border-color:var(--acc);background:rgba(234,179,8,0.1)}
173
+ .export-opt input{display:none}
174
+ .export-opt .radio{width:16px;height:16px;border:2px solid var(--brd);border-radius:50%;flex-shrink:0;margin-top:2px;position:relative}
175
+ .export-opt.selected .radio{border-color:var(--acc)}
176
+ .export-opt.selected .radio::after{content:'';position:absolute;top:3px;left:3px;width:6px;height:6px;background:var(--acc);border-radius:50%}
177
+ .export-opt-content{flex:1;min-width:0}
178
+ .export-opt-title{font-size:12px;color:var(--txt);font-weight:500;margin-bottom:2px}
179
+ .export-opt-desc{font-size:10px;color:var(--txt3)}
180
+ .export-opt-thumb{width:40px;height:40px;border-radius:4px;overflow:hidden;flex-shrink:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/8px 8px}
181
+ .export-opt-thumb img{width:100%;height:100%;object-fit:contain}
182
+ .export-modal-btns{display:flex;gap:8px;justify-content:flex-end}
183
+ .export-modal-btns button{padding:8px 16px;font-size:11px;border-radius:4px;cursor:pointer;font-weight:500}
184
+ .export-modal-btns .cancel{background:var(--bg3);border:1px solid var(--brd);color:var(--txt)}
185
+ .export-modal-btns .cancel:hover{border-color:var(--acc)}
186
+ .export-modal-btns .confirm{background:var(--acc);border:none;color:#000}
187
+ .export-modal-btns .confirm:hover{background:var(--acc2)}
188
+ /* Left divider for frame panel */
189
+ .divider-left{display:none;width:6px;cursor:col-resize;background:transparent;align-items:center;justify-content:center;flex-shrink:0;margin:0 -2px}
190
+ .divider-left.show{display:flex}
191
+ .divider-left:hover,.divider-left.dragging{background:var(--bg3)}
192
+ .divider-left::after{content:'';width:2px;height:40px;background:var(--brd);border-radius:1px;transition:background .2s}
193
+ .divider-left:hover::after,.divider-left.dragging::after{background:var(--acc)}
194
+ </style>
195
+ </head>
196
+ <body>
197
+ <div class="app">
198
+ <div class="hd">
199
+ <div class="hd-top">
200
+ <div class="hd-brand">
201
+ <div class="hd-title">
202
+ <b>PicLet</b>
203
+ <span class="hd-subtitle">Edit. Scale. Ship.</span>
204
+ </div>
205
+ <span class="hd-desc">Lightweight image tools for content creators</span>
206
+ </div>
207
+ <div class="hd-links">
208
+ <a href="https://buymeacoffee.com/spark88" class="hd-coffee" onclick="PicLet.openUrl('https://buymeacoffee.com/spark88');return false">
209
+ <svg viewBox="0 0 24 24"><path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364z"/></svg>
210
+ Donate
211
+ </a>
212
+ <a href="https://piclet.app" class="hd-link" onclick="PicLet.openUrl('https://piclet.app');return false">piclet.app</a>
213
+ </div>
214
+ </div>
215
+ <div class="hd-meta">
216
+ <div>File<b id="fileName">-</b></div>
217
+ <div>Original<b id="origSize">-</b></div>
218
+ </div>
219
+ </div>
220
+
221
+ <!-- Main content -->
222
+ <div class="main" id="M">
223
+ <!-- GIF Frame Panel (left) -->
224
+ <div class="frame-panel" id="framePanel">
225
+ <div class="frame-panel-header">
226
+ <b id="frameCount">0</b>
227
+ <span>Frames</span>
228
+ </div>
229
+ <div class="frame-strip-scroll" id="frameScroll"></div>
230
+ <button class="gif-export-btn" id="gifExportBtn" onclick="showExportModal()">Export</button>
231
+ </div>
232
+
233
+ <!-- Left divider (for frame panel) -->
234
+ <div class="divider-left" id="dividerLeft"></div>
235
+
236
+ <!-- Preview panel -->
237
+ <div class="preview-panel">
238
+ <div class="preview-area" id="pA">
239
+ <span class="placeholder">Enable tools to preview</span>
240
+ </div>
241
+ <div class="preview-info" id="pI"></div>
242
+ <div class="preview-zoom">
243
+ <button onclick="zoomOut()" title="Zoom out">−</button>
244
+ <span id="zoomLevel">100%</span>
245
+ <button onclick="zoomIn()" title="Zoom in">+</button>
246
+ <button id="fitBtn" onclick="zoomReset()" title="Reset zoom" style="font-size:10px;width:28px;display:none">Fit</button>
247
+ <span class="zoom-sep"></span>
248
+ <div class="play-controls" id="playControls">
249
+ <button id="playBtn" onclick="togglePlayback()" title="Play/Pause">▶</button>
250
+ <select id="playSpeed" onchange="setPlaySpeed(this.value)" title="Playback speed">
251
+ <option value="200">0.5×</option>
252
+ <option value="100" selected>1×</option>
253
+ <option value="50">2×</option>
254
+ <option value="33">3×</option>
255
+ </select>
256
+ </div>
257
+ </div>
258
+ <button class="load-btn" onclick="loadNewImage()">Load Different Image...</button>
259
+ <input type="file" id="fileInput" accept=".png,.jpg,.jpeg,.gif,.bmp,.ico" style="display:none" onchange="handleFileSelect(event)">
260
+ </div>
261
+
262
+ <!-- Resizable divider -->
263
+ <div class="divider" id="divider"></div>
264
+
265
+ <!-- Tools panel -->
266
+ <div class="tools-panel">
267
+ <!-- Remove Background -->
268
+ <div class="tool" id="t-removebg" data-tool="removebg" data-ext=".png,.jpg,.jpeg,.ico,.gif">
269
+ <div class="tool-header" onclick="toggleTool('removebg')">
270
+ <img src="/icons/removebg.ico" alt="">
271
+ <span class="name">Remove Background</span>
272
+ <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
273
+ </div>
274
+ <div class="tool-body">
275
+ <div class="tool-opts">
276
+ <div class="tool-row">
277
+ <label>Tolerance</label>
278
+ <input type="range" id="rb-fuzz" min="0" max="100" value="10" oninput="$('rb-fuzzV').textContent=this.value+'%'" onchange="schedulePreview()">
279
+ <span class="val" id="rb-fuzzV">10%</span>
280
+ </div>
281
+ <label class="opt"><input type="checkbox" id="rb-trim" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Auto-trim edges</label>
282
+ <label class="opt"><input type="checkbox" id="rb-edges" onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Border only (preserve inner)</label>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Scale Image -->
288
+ <div class="tool" id="t-scale" data-tool="scale" data-ext=".png,.jpg,.jpeg,.gif,.bmp,.ico">
289
+ <div class="tool-header" onclick="toggleTool('scale')">
290
+ <img src="/icons/rescale.ico" alt="">
291
+ <span class="name">Scale Image</span>
292
+ <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
293
+ </div>
294
+ <div class="tool-body">
295
+ <div class="tool-opts">
296
+ <div class="tool-row">
297
+ <label>Width</label>
298
+ <input type="range" id="sc-w" min="16" max="512" value="512" oninput="scaleSlide('w')" onchange="schedulePreview()">
299
+ <span class="val" id="sc-wV">512px</span>
300
+ </div>
301
+ <div class="tool-row" id="sc-hRow">
302
+ <label>Height</label>
303
+ <input type="range" id="sc-h" min="16" max="512" value="512" oninput="scaleSlide('h')" onchange="schedulePreview()">
304
+ <span class="val" id="sc-hV">512px</span>
305
+ </div>
306
+ <label class="opt"><input type="checkbox" id="sc-lock" onchange="toggleRatioLock()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Lock aspect ratio</label>
307
+ <label class="opt"><input type="checkbox" id="sc-sq" onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Square output (add padding)</label>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ <!-- Generate Icons (combined ICO + Icon Pack) -->
313
+ <div class="tool" id="t-icons" data-tool="icons" data-ext=".png,.jpg,.jpeg,.ico,.gif">
314
+ <div class="tool-header" onclick="toggleTool('icons')">
315
+ <img src="/icons/iconpack.ico" alt="">
316
+ <span class="name">Generate Icons</span>
317
+ <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
318
+ </div>
319
+ <div class="tool-body">
320
+ <div class="tool-opts">
321
+ <label class="opt"><input type="checkbox" id="ic-trim" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Auto-trim transparent edges</label>
322
+ <label class="opt"><input type="checkbox" id="ic-sq" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Make square before generating</label>
323
+ </div>
324
+ <div style="font-size:9px;color:var(--txt3);margin:6px 0 4px;text-transform:uppercase">Output Formats</div>
325
+ <div class="platforms">
326
+ <label class="opt"><input type="checkbox" id="ic-ico" checked><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>ICO</label>
327
+ <label class="opt"><input type="checkbox" id="ic-web"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Web</label>
328
+ <label class="opt"><input type="checkbox" id="ic-android"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Android</label>
329
+ <label class="opt"><input type="checkbox" id="ic-ios"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>iOS</label>
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ <!-- Generate Store Assets -->
335
+ <div class="tool" id="t-storepack" data-tool="storepack" data-ext=".png,.jpg,.jpeg,.gif">
336
+ <div class="tool-header" onclick="toggleTool('storepack')">
337
+ <img src="/icons/storepack.ico" alt="">
338
+ <span class="name">Generate Store Assets</span>
339
+ <span class="toggle"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>
340
+ </div>
341
+ <div class="tool-body">
342
+ <div class="tool-opts">
343
+ <div class="tool-row">
344
+ <label>Preset</label>
345
+ <select id="sp-preset" onchange="onPresetChange()"></select>
346
+ </div>
347
+ <div class="tool-row">
348
+ <label>Scale</label>
349
+ <select id="sp-mode">
350
+ <option value="fit">Fit (letterbox)</option>
351
+ <option value="fill">Fill (crop)</option>
352
+ <option value="stretch">Stretch</option>
353
+ </select>
354
+ </div>
355
+ </div>
356
+ <div style="font-size:9px;color:var(--txt3);margin:8px 0 4px;text-transform:uppercase">Output Sizes</div>
357
+ <div class="dim-list" id="sp-dims"></div>
358
+ <div class="dim-add">
359
+ <input type="number" id="sp-newW" placeholder="W" min="1" max="9999">
360
+ <span>×</span>
361
+ <input type="number" id="sp-newH" placeholder="H" min="1" max="9999">
362
+ <button class="dim-add-btn" onclick="addDimension()">+</button>
363
+ </div>
364
+ <div class="preset-actions">
365
+ <input type="text" id="sp-name" placeholder="Preset name...">
366
+ <button class="btn-sm" onclick="saveCurrentPreset()">Save</button>
367
+ <button class="btn-sm btn-del" onclick="deleteCurrentPreset()">Delete</button>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- Bottom bar -->
375
+ <div class="bottom-bar" id="B">
376
+ <button class="btn btn-g" onclick="tryClose()">Close</button>
377
+ <button class="btn apply-btn" id="applyBtn" onclick="apply()" disabled>Apply</button>
378
+ </div>
379
+
380
+ <!-- Loading state -->
381
+ <div class="ld" id="L"><div class="sp"></div><span id="lT">Processing...</span></div>
382
+ <!-- Log -->
383
+ <div class="log" id="G"></div>
384
+ <!-- Done state -->
385
+ <div class="dn" id="D">
386
+ <h4 id="dT"></h4>
387
+ <p id="dM"></p>
388
+ <div class="btns" style="width:100%;margin-top:8px">
389
+ <button class="btn btn-g" onclick="reset()">Back</button>
390
+ <button class="btn" onclick="openOutputFolder()" id="openFolderBtn" style="display:none">Open Folder</button>
391
+ <button class="btn btn-p" onclick="PicLet.close()">Done</button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+
396
+ <!-- Themed alert/confirm modal -->
397
+ <div class="modal-overlay" id="modal" onclick="hideModal(event)">
398
+ <div class="modal">
399
+ <div class="modal-msg" id="modalMsg"></div>
400
+ <div class="modal-btns" id="modalBtns">
401
+ <button class="modal-btn" onclick="hideModal()">OK</button>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ <!-- Export modal -->
407
+ <div class="modal-overlay" id="exportModal" onclick="hideExportModal(event)">
408
+ <div class="export-modal" onclick="event.stopPropagation()">
409
+ <h3>Export GIF</h3>
410
+ <div class="export-options">
411
+ <label class="export-opt selected" onclick="selectExportOption('frame')">
412
+ <input type="radio" name="exportType" value="frame" checked>
413
+ <span class="radio"></span>
414
+ <div class="export-opt-content">
415
+ <div class="export-opt-title">Current Frame</div>
416
+ <div class="export-opt-desc">Save frame <span id="exportFrameNum">1</span> as PNG</div>
417
+ </div>
418
+ <div class="export-opt-thumb" id="exportThumb"></div>
419
+ </label>
420
+ <label class="export-opt" onclick="selectExportOption('all-frames')">
421
+ <input type="radio" name="exportType" value="all-frames">
422
+ <span class="radio"></span>
423
+ <div class="export-opt-content">
424
+ <div class="export-opt-title">All Frames (ZIP)</div>
425
+ <div class="export-opt-desc">Save all <span id="exportTotalFrames">0</span> frames as PNG in a ZIP file</div>
426
+ </div>
427
+ </label>
428
+ <label class="export-opt" onclick="selectExportOption('gif')">
429
+ <input type="radio" name="exportType" value="gif">
430
+ <span class="radio"></span>
431
+ <div class="export-opt-content">
432
+ <div class="export-opt-title">Processed GIF</div>
433
+ <div class="export-opt-desc">Save as animated GIF with all effects applied</div>
434
+ </div>
435
+ </label>
436
+ </div>
437
+ <div class="export-modal-btns">
438
+ <button class="cancel" onclick="hideExportModal()">Cancel</button>
439
+ <button class="confirm" onclick="confirmExport()">Export</button>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <script>
445
+ const { $, log, fetchJson, postJson } = PicLet;
446
+
447
+ // Themed alert/confirm
448
+ let modalCallback = null;
449
+
450
+ function showAlert(msg) {
451
+ $('modalMsg').textContent = msg;
452
+ $('modalBtns').innerHTML = '<button class="modal-btn" onclick="hideModal()">OK</button>';
453
+ $('modal').classList.add('on');
454
+ modalCallback = null;
455
+ }
456
+
457
+ function showConfirm(msg, onConfirm, dangerText = 'Delete') {
458
+ $('modalMsg').textContent = msg;
459
+ $('modalBtns').innerHTML = `
460
+ <button class="modal-btn secondary" onclick="hideModal()">Cancel</button>
461
+ <button class="modal-btn danger" onclick="confirmModal()">${dangerText}</button>
462
+ `;
463
+ $('modal').classList.add('on');
464
+ modalCallback = onConfirm;
465
+ }
466
+
467
+ function hideModal(e) {
468
+ if (!e || e.target === $('modal')) {
469
+ $('modal').classList.remove('on');
470
+ modalCallback = null;
471
+ }
472
+ }
473
+
474
+ function confirmModal() {
475
+ $('modal').classList.remove('on');
476
+ if (modalCallback) modalCallback();
477
+ modalCallback = null;
478
+ }
479
+ const pA = $('pA'), pI = $('pI');
480
+
481
+ // Zoom and pan state
482
+ let zoomScale = 1;
483
+ const ZOOM_MIN = 0.25;
484
+ const ZOOM_MAX = 4;
485
+ const ZOOM_STEP = 0.25;
486
+
487
+ function updateZoom() {
488
+ const img = pA.querySelector('img');
489
+ if (img) {
490
+ img.style.transform = `scale(${zoomScale})`;
491
+ // Remove max constraints when zoomed in
492
+ if (zoomScale > 1) {
493
+ img.style.maxWidth = 'none';
494
+ img.style.maxHeight = 'none';
495
+ } else {
496
+ img.style.maxWidth = '100%';
497
+ img.style.maxHeight = '100%';
498
+ }
499
+ }
500
+ $('zoomLevel').textContent = Math.round(zoomScale * 100) + '%';
501
+ // Show Fit button only when zoomed in or out
502
+ $('fitBtn').style.display = zoomScale !== 1 ? '' : 'none';
503
+ }
504
+
505
+ function zoomIn() {
506
+ zoomScale = Math.min(ZOOM_MAX, zoomScale + ZOOM_STEP);
507
+ updateZoom();
508
+ }
509
+
510
+ function zoomOut() {
511
+ zoomScale = Math.max(ZOOM_MIN, zoomScale - ZOOM_STEP);
512
+ updateZoom();
513
+ }
514
+
515
+ function zoomReset() {
516
+ zoomScale = 1;
517
+ pA.scrollLeft = 0;
518
+ pA.scrollTop = 0;
519
+ updateZoom();
520
+ }
521
+
522
+ // Mouse wheel zoom (no modifier key needed)
523
+ pA.addEventListener('wheel', (e) => {
524
+ e.preventDefault();
525
+ if (e.deltaY < 0) zoomIn();
526
+ else zoomOut();
527
+ }, { passive: false });
528
+
529
+ // Mouse drag panning
530
+ let isPanning = false;
531
+ let panStart = { x: 0, y: 0 };
532
+ let scrollStart = { x: 0, y: 0 };
533
+
534
+ pA.addEventListener('mousedown', (e) => {
535
+ // Only pan on left click and if there's an image
536
+ if (e.button !== 0 || !pA.querySelector('img')) return;
537
+ isPanning = true;
538
+ panStart = { x: e.clientX, y: e.clientY };
539
+ scrollStart = { x: pA.scrollLeft, y: pA.scrollTop };
540
+ const img = pA.querySelector('img');
541
+ if (img) img.classList.add('panning');
542
+ e.preventDefault();
543
+ });
544
+
545
+ document.addEventListener('mousemove', (e) => {
546
+ if (!isPanning) return;
547
+ const dx = e.clientX - panStart.x;
548
+ const dy = e.clientY - panStart.y;
549
+ pA.scrollLeft = scrollStart.x - dx;
550
+ pA.scrollTop = scrollStart.y - dy;
551
+ });
552
+
553
+ document.addEventListener('mouseup', () => {
554
+ if (isPanning) {
555
+ isPanning = false;
556
+ const img = pA.querySelector('img');
557
+ if (img) img.classList.remove('panning');
558
+ }
559
+ });
560
+
561
+ let imageInfo = {};
562
+ let previewTimeout = null;
563
+ let activeTools = new Set();
564
+ let lastOutputSuccess = false;
565
+ let hasChanges = false;
566
+ let presets = [];
567
+ let currentDimensions = []; // Editable dimensions for storepack
568
+
569
+ // GIF frame state
570
+ let isGifFile = false;
571
+ let gifFrames = []; // Array of { index, thumbnail (base64) }
572
+ let selectedFrameIndex = 0;
573
+ let framePreviewCache = {}; // Cache processed frame previews
574
+
575
+ // Toggle tool active state
576
+ function toggleTool(tool) {
577
+ const el = $('t-' + tool);
578
+ if (!el || el.classList.contains('disabled')) return;
579
+
580
+ if (activeTools.has(tool)) {
581
+ activeTools.delete(tool);
582
+ el.classList.remove('active');
583
+ } else {
584
+ activeTools.add(tool);
585
+ el.classList.add('active');
586
+ hasChanges = true;
587
+ }
588
+
589
+ updateApplyButton();
590
+ schedulePreview();
591
+ }
592
+
593
+ // Update apply button state
594
+ function updateApplyButton() {
595
+ $('applyBtn').disabled = activeTools.size === 0;
596
+ }
597
+
598
+ // Filter tools by file extension
599
+ function filterTools(ext) {
600
+ document.querySelectorAll('.tool').forEach(el => {
601
+ const exts = el.dataset.ext.split(',');
602
+ el.classList.toggle('disabled', !exts.includes(ext));
603
+ if (!exts.includes(ext)) {
604
+ activeTools.delete(el.dataset.tool);
605
+ el.classList.remove('active');
606
+ }
607
+ });
608
+ }
609
+
610
+ // Scale slider update with aspect ratio lock
611
+ let aspectRatio = 1;
612
+ let scaleDebounce = null;
613
+
614
+ function scaleSlide(dim) {
615
+ const locked = $('sc-lock').checked;
616
+ const w = $('sc-w'), h = $('sc-h');
617
+
618
+ if (locked) {
619
+ // When locked, only width slider is used - calculate height from ratio
620
+ const newW = +w.value;
621
+ const newH = Math.round(newW / aspectRatio);
622
+ h.value = Math.min(newH, +h.max);
623
+ $('sc-wV').textContent = newW + 'px';
624
+ $('sc-hV').textContent = h.value + 'px';
625
+ } else {
626
+ // Independent sliders
627
+ $('sc-' + dim + 'V').textContent = $('sc-' + dim).value + 'px';
628
+ }
629
+
630
+ // Debounced preview update while sliding
631
+ if (scaleDebounce) clearTimeout(scaleDebounce);
632
+ scaleDebounce = setTimeout(schedulePreview, 150);
633
+ }
634
+
635
+ function toggleRatioLock() {
636
+ const locked = $('sc-lock').checked;
637
+ $('sc-hRow').style.display = locked ? 'none' : 'flex';
638
+
639
+ if (locked) {
640
+ // Recalculate height based on current width and aspect ratio
641
+ const newH = Math.round(+$('sc-w').value / aspectRatio);
642
+ $('sc-h').value = Math.min(newH, +$('sc-h').max);
643
+ $('sc-hV').textContent = $('sc-h').value + 'px';
644
+ }
645
+ schedulePreview();
646
+ }
647
+
648
+ // Get combined options for all active tools
649
+ function getOptions() {
650
+ const opts = { tools: Array.from(activeTools) };
651
+
652
+ if (activeTools.has('removebg')) {
653
+ opts.removebg = {
654
+ fuzz: +$('rb-fuzz').value,
655
+ trim: $('rb-trim').checked,
656
+ preserveInner: $('rb-edges').checked
657
+ };
658
+ }
659
+
660
+ if (activeTools.has('scale')) {
661
+ opts.scale = {
662
+ width: +$('sc-w').value,
663
+ height: +$('sc-h').value,
664
+ makeSquare: $('sc-sq').checked
665
+ };
666
+ }
667
+
668
+ if (activeTools.has('icons')) {
669
+ opts.icons = {
670
+ trim: $('ic-trim').checked,
671
+ makeSquare: $('ic-sq').checked,
672
+ ico: $('ic-ico').checked,
673
+ web: $('ic-web').checked,
674
+ android: $('ic-android').checked,
675
+ ios: $('ic-ios').checked
676
+ };
677
+ }
678
+
679
+ if (activeTools.has('storepack')) {
680
+ const presetSel = $('sp-preset');
681
+ const presetName = presetSel.value || $('sp-name').value.trim() || 'custom';
682
+ opts.storepack = {
683
+ dimensions: currentDimensions,
684
+ scaleMode: $('sp-mode').value,
685
+ presetName: presetName
686
+ };
687
+ }
688
+
689
+ return opts;
690
+ }
691
+
692
+ // Schedule preview
693
+ function schedulePreview() {
694
+ if (previewTimeout) clearTimeout(previewTimeout);
695
+ previewTimeout = setTimeout(generatePreview, 300);
696
+ }
697
+
698
+ // Show original image
699
+ async function showOriginal() {
700
+ // For GIFs with frames loaded, show selected frame directly
701
+ if (isGifFile && gifFrames.length > 0 && gifFrames[selectedFrameIndex]) {
702
+ const frame = gifFrames[selectedFrameIndex];
703
+ pA.innerHTML = '';
704
+ const img = document.createElement('img');
705
+ img.src = frame.thumbnail;
706
+ pA.appendChild(img);
707
+ pI.textContent = `${imageInfo.width} × ${imageInfo.height} (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})`;
708
+ updateZoom();
709
+ return;
710
+ }
711
+
712
+ pA.innerHTML = '<div class="mini-sp"></div>';
713
+ try {
714
+ const opts = { tools: [], original: true };
715
+ if (isGifFile) {
716
+ opts.frameIndex = selectedFrameIndex;
717
+ }
718
+ const result = await postJson('/api/preview', opts);
719
+ if (result.success && result.imageData) {
720
+ const img = document.createElement('img');
721
+ img.src = result.imageData;
722
+ pA.innerHTML = '';
723
+ pA.appendChild(img);
724
+ const frameInfo = isGifFile && imageInfo.frameCount > 1 ? ` (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})` : '';
725
+ pI.textContent = `${imageInfo.width} × ${imageInfo.height}${frameInfo}`;
726
+ updateZoom(); // Apply current zoom
727
+ }
728
+ } catch (e) {
729
+ pA.innerHTML = '<span class="placeholder">Failed to load image</span>';
730
+ }
731
+ }
732
+
733
+ // Generate preview
734
+ async function generatePreview() {
735
+ if (activeTools.size === 0) {
736
+ showOriginal();
737
+ // Reset frame thumbnails to original if GIF
738
+ if (isGifFile && gifFrames.length > 0) {
739
+ updateFramePreviews();
740
+ }
741
+ return;
742
+ }
743
+
744
+ // Icon pack and store pack don't have meaningful previews (icons does though - shows trimmed/squared source)
745
+ const previewableTools = ['removebg', 'scale', 'icons'];
746
+ const hasPreviewable = Array.from(activeTools).some(t => previewableTools.includes(t));
747
+
748
+ if (!hasPreviewable) {
749
+ showOriginal();
750
+ return;
751
+ }
752
+
753
+ pA.innerHTML = '<div class="mini-sp"></div>';
754
+ pI.textContent = '';
755
+
756
+ try {
757
+ // For GIFs, preview the selected frame
758
+ const opts = getOptions();
759
+ if (isGifFile) {
760
+ opts.frameIndex = selectedFrameIndex;
761
+ }
762
+
763
+ const result = await postJson('/api/preview', opts);
764
+ if (result.success && result.imageData) {
765
+ const img = document.createElement('img');
766
+ img.src = result.imageData;
767
+ pA.innerHTML = '';
768
+ pA.appendChild(img);
769
+ if (result.width && result.height) {
770
+ const frameInfo = isGifFile ? ` (Frame ${selectedFrameIndex + 1})` : '';
771
+ pI.textContent = `${result.width} × ${result.height}${frameInfo}`;
772
+ }
773
+ updateZoom(); // Apply current zoom
774
+ } else {
775
+ pA.innerHTML = '<span class="placeholder">' + (result.error || 'Preview failed') + '</span>';
776
+ }
777
+
778
+ // Update all frame thumbnails for GIFs (in background)
779
+ if (isGifFile && gifFrames.length > 0) {
780
+ updateFramePreviews();
781
+ }
782
+ } catch (e) {
783
+ pA.innerHTML = '<span class="placeholder">Preview error</span>';
784
+ }
785
+ }
786
+
787
+ // Load new image
788
+ function loadNewImage() {
789
+ $('fileInput').click();
790
+ }
791
+
792
+ async function handleFileSelect(e) {
793
+ const file = e.target.files[0];
794
+ if (!file) return;
795
+ await loadImageFile(file);
796
+ e.target.value = '';
797
+ }
798
+
799
+ // Load image file (shared by file input and drag/drop)
800
+ async function loadImageFile(file) {
801
+ const reader = new FileReader();
802
+ reader.onload = async () => {
803
+ try {
804
+ const base64 = reader.result.split(',')[1]; // Remove data URL prefix
805
+ const result = await postJson('/api/load', {
806
+ fileName: file.name,
807
+ data: base64,
808
+ mimeType: file.type
809
+ });
810
+ if (result.success) {
811
+ imageInfo = result;
812
+ updateImageInfo();
813
+ zoomReset(); // Reset zoom for new image
814
+ showOriginal();
815
+ } else {
816
+ showAlert('Failed to load image: ' + (result.error || 'Unknown error'));
817
+ }
818
+ } catch (err) {
819
+ showAlert('Failed to load image: ' + err.message);
820
+ }
821
+ };
822
+ reader.readAsDataURL(file);
823
+ }
824
+
825
+ // Drag and drop handlers (only for external file drops)
826
+ function setupDragDrop() {
827
+ const area = pA;
828
+
829
+ area.addEventListener('dragenter', (e) => {
830
+ // Only respond to file drags from outside
831
+ if (e.dataTransfer?.types?.includes('Files')) {
832
+ e.preventDefault();
833
+ area.classList.add('dragover');
834
+ }
835
+ });
836
+
837
+ area.addEventListener('dragover', (e) => {
838
+ if (e.dataTransfer?.types?.includes('Files')) {
839
+ e.preventDefault();
840
+ }
841
+ });
842
+
843
+ area.addEventListener('dragleave', (e) => {
844
+ // Only remove if actually leaving the area
845
+ if (!area.contains(e.relatedTarget)) {
846
+ area.classList.remove('dragover');
847
+ }
848
+ });
849
+
850
+ area.addEventListener('drop', (e) => {
851
+ e.preventDefault();
852
+ area.classList.remove('dragover');
853
+
854
+ const files = e.dataTransfer?.files;
855
+ if (files && files.length > 0) {
856
+ const file = files[0];
857
+ // Check if it's an image
858
+ if (file.type.startsWith('image/') || /\.(png|jpg|jpeg|gif|bmp|ico)$/i.test(file.name)) {
859
+ loadImageFile(file);
860
+ }
861
+ }
862
+ });
863
+ }
864
+
865
+ // Update displayed image info
866
+ function updateImageInfo() {
867
+ $('fileName').textContent = imageInfo.fileName;
868
+ $('origSize').textContent = imageInfo.width + '×' + imageInfo.height;
869
+
870
+ const ext = '.' + imageInfo.fileName.split('.').pop().toLowerCase();
871
+ filterTools(ext);
872
+
873
+ // Check if GIF
874
+ isGifFile = ext === '.gif';
875
+ if (isGifFile && imageInfo.frameCount > 1) {
876
+ showFrameStrip();
877
+ } else {
878
+ hideFrameStrip();
879
+ }
880
+
881
+ // Calculate aspect ratio
882
+ aspectRatio = imageInfo.width / imageInfo.height;
883
+
884
+ // Set scale sliders max to image dimensions (can't upscale)
885
+ $('sc-w').max = imageInfo.width;
886
+ $('sc-h').max = imageInfo.height;
887
+ $('sc-w').value = imageInfo.width;
888
+ $('sc-h').value = imageInfo.height;
889
+ $('sc-wV').textContent = imageInfo.width + 'px';
890
+ $('sc-hV').textContent = imageInfo.height + 'px';
891
+ }
892
+
893
+ // ── GIF Frame Strip Functions ──
894
+
895
+ // Animation playback state
896
+ let isPlaying = false;
897
+ let playInterval = null;
898
+ let playSpeed = 100; // ms per frame
899
+
900
+ function showFrameStrip() {
901
+ $('framePanel').classList.add('show');
902
+ $('dividerLeft').classList.add('show');
903
+ $('playControls').classList.add('show');
904
+ $('gifExportBtn').classList.add('show');
905
+ $('frameCount').textContent = imageInfo.frameCount;
906
+ selectedFrameIndex = 0;
907
+ gifFrames = [];
908
+ framePreviewCache = {};
909
+ loadFrameThumbnails();
910
+ }
911
+
912
+ function hideFrameStrip() {
913
+ $('framePanel').classList.remove('show');
914
+ $('dividerLeft').classList.remove('show');
915
+ $('playControls').classList.remove('show');
916
+ $('gifExportBtn').classList.remove('show');
917
+ stopPlayback();
918
+ gifFrames = [];
919
+ framePreviewCache = {};
920
+ $('frameScroll').innerHTML = '';
921
+ }
922
+
923
+ // Export modal
924
+ let selectedExportOption = 'frame';
925
+
926
+ function showExportModal() {
927
+ stopPlayback();
928
+
929
+ // Update modal with current frame info
930
+ $('exportFrameNum').textContent = selectedFrameIndex + 1;
931
+ $('exportTotalFrames').textContent = imageInfo.frameCount;
932
+
933
+ // Set thumbnail preview
934
+ const thumbContainer = $('exportThumb');
935
+ thumbContainer.innerHTML = '';
936
+ if (gifFrames[selectedFrameIndex] && gifFrames[selectedFrameIndex].thumbnail) {
937
+ const img = document.createElement('img');
938
+ img.src = gifFrames[selectedFrameIndex].thumbnail;
939
+ thumbContainer.appendChild(img);
940
+ }
941
+
942
+ // Reset selection to first option
943
+ selectedExportOption = 'frame';
944
+ document.querySelectorAll('.export-opt').forEach(opt => {
945
+ const input = opt.querySelector('input');
946
+ opt.classList.toggle('selected', input.value === 'frame');
947
+ input.checked = input.value === 'frame';
948
+ });
949
+
950
+ $('exportModal').classList.add('on');
951
+ }
952
+
953
+ function hideExportModal(e) {
954
+ if (!e || e.target === $('exportModal')) {
955
+ $('exportModal').classList.remove('on');
956
+ }
957
+ }
958
+
959
+ function selectExportOption(value) {
960
+ selectedExportOption = value;
961
+ document.querySelectorAll('.export-opt').forEach(opt => {
962
+ const input = opt.querySelector('input');
963
+ opt.classList.toggle('selected', input.value === value);
964
+ input.checked = input.value === value;
965
+ });
966
+ }
967
+
968
+ function confirmExport() {
969
+ hideExportModal();
970
+
971
+ switch (selectedExportOption) {
972
+ case 'frame':
973
+ exportSelectedFrame();
974
+ break;
975
+ case 'all-frames':
976
+ exportAllFrames();
977
+ break;
978
+ case 'gif':
979
+ exportAsGif();
980
+ break;
981
+ }
982
+ }
983
+
984
+ function togglePlayback() {
985
+ if (isPlaying) {
986
+ stopPlayback();
987
+ } else {
988
+ startPlayback();
989
+ }
990
+ }
991
+
992
+ function startPlayback() {
993
+ if (gifFrames.length < 2) return;
994
+ isPlaying = true;
995
+ $('playBtn').textContent = '⏸';
996
+ $('playBtn').classList.add('playing');
997
+
998
+ playInterval = setInterval(() => {
999
+ selectedFrameIndex = (selectedFrameIndex + 1) % gifFrames.length;
1000
+ updatePlaybackFrame();
1001
+ }, playSpeed);
1002
+ }
1003
+
1004
+ function stopPlayback() {
1005
+ isPlaying = false;
1006
+ if (playInterval) {
1007
+ clearInterval(playInterval);
1008
+ playInterval = null;
1009
+ }
1010
+ $('playBtn').textContent = '▶';
1011
+ $('playBtn').classList.remove('playing');
1012
+ }
1013
+
1014
+ function setPlaySpeed(ms) {
1015
+ playSpeed = parseInt(ms, 10);
1016
+ if (isPlaying) {
1017
+ stopPlayback();
1018
+ startPlayback();
1019
+ }
1020
+ }
1021
+
1022
+ function updatePlaybackFrame() {
1023
+ // Update thumbnail selection (no scrolling during playback - it's distracting)
1024
+ document.querySelectorAll('.frame-thumb').forEach((thumb, i) => {
1025
+ thumb.classList.toggle('selected', i === selectedFrameIndex);
1026
+ });
1027
+
1028
+ // Update main preview
1029
+ const frame = gifFrames[selectedFrameIndex];
1030
+ if (frame && frame.thumbnail) {
1031
+ const img = pA.querySelector('img');
1032
+ if (img) {
1033
+ img.src = frame.thumbnail;
1034
+ } else {
1035
+ pA.innerHTML = '';
1036
+ const newImg = document.createElement('img');
1037
+ newImg.src = frame.thumbnail;
1038
+ pA.appendChild(newImg);
1039
+ updateZoom();
1040
+ }
1041
+ pI.textContent = `Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount}`;
1042
+ }
1043
+ }
1044
+
1045
+ async function loadFrameThumbnails() {
1046
+ const scroll = $('frameScroll');
1047
+ scroll.innerHTML = '';
1048
+
1049
+ // Create placeholder thumbnails first
1050
+ for (let i = 0; i < imageInfo.frameCount; i++) {
1051
+ const thumb = document.createElement('div');
1052
+ thumb.className = 'frame-thumb' + (i === 0 ? ' selected' : '');
1053
+ thumb.dataset.index = i;
1054
+ thumb.innerHTML = `<span class="frame-num">${i + 1}</span>`;
1055
+ thumb.onclick = () => selectFrame(i);
1056
+ scroll.appendChild(thumb);
1057
+ }
1058
+
1059
+ // Load thumbnails in batches
1060
+ for (let i = 0; i < imageInfo.frameCount; i++) {
1061
+ try {
1062
+ const result = await postJson('/api/frame-thumbnail', { frameIndex: i });
1063
+ if (result.success && result.imageData) {
1064
+ const thumb = scroll.children[i];
1065
+ const img = document.createElement('img');
1066
+ img.src = result.imageData;
1067
+ thumb.insertBefore(img, thumb.firstChild);
1068
+ gifFrames[i] = { index: i, thumbnail: result.imageData };
1069
+ }
1070
+ } catch (e) {
1071
+ console.error('Failed to load frame', i, e);
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ function selectFrame(index) {
1077
+ // Stop playback on manual selection
1078
+ stopPlayback();
1079
+
1080
+ selectedFrameIndex = index;
1081
+
1082
+ // Update selection UI
1083
+ document.querySelectorAll('.frame-thumb').forEach((thumb, i) => {
1084
+ thumb.classList.toggle('selected', i === index);
1085
+ });
1086
+
1087
+ // Show selected frame in main preview
1088
+ showSelectedFrame();
1089
+ }
1090
+
1091
+ async function showSelectedFrame() {
1092
+ if (activeTools.size === 0) {
1093
+ // Show original frame
1094
+ const frame = gifFrames[selectedFrameIndex];
1095
+ if (frame && frame.thumbnail) {
1096
+ pA.innerHTML = '';
1097
+ const img = document.createElement('img');
1098
+ img.src = frame.thumbnail;
1099
+ pA.appendChild(img);
1100
+ pI.textContent = `Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount}`;
1101
+ updateZoom();
1102
+ }
1103
+ } else {
1104
+ // Regenerate preview for selected frame
1105
+ generatePreview();
1106
+ }
1107
+ }
1108
+
1109
+ // Update frame thumbnails with processed preview
1110
+ async function updateFramePreviews() {
1111
+ if (!isGifFile || gifFrames.length === 0) return;
1112
+
1113
+ const opts = getOptions();
1114
+ if (activeTools.size === 0) {
1115
+ // Reset to original thumbnails
1116
+ document.querySelectorAll('.frame-thumb').forEach((thumb, i) => {
1117
+ thumb.classList.remove('processing');
1118
+ const img = thumb.querySelector('img');
1119
+ if (img && gifFrames[i]) {
1120
+ img.src = gifFrames[i].thumbnail;
1121
+ }
1122
+ });
1123
+ return;
1124
+ }
1125
+
1126
+ // Show processing state on all thumbnails
1127
+ document.querySelectorAll('.frame-thumb').forEach(thumb => {
1128
+ thumb.classList.add('processing');
1129
+ });
1130
+
1131
+ // Process frames (limit concurrent requests)
1132
+ for (let i = 0; i < gifFrames.length; i++) {
1133
+ try {
1134
+ const result = await postJson('/api/frame-preview', {
1135
+ frameIndex: i,
1136
+ ...opts
1137
+ });
1138
+ const thumb = document.querySelector(`.frame-thumb[data-index="${i}"]`);
1139
+ if (thumb) {
1140
+ thumb.classList.remove('processing');
1141
+ if (result.success && result.imageData) {
1142
+ let img = thumb.querySelector('img');
1143
+ if (!img) {
1144
+ img = document.createElement('img');
1145
+ thumb.insertBefore(img, thumb.firstChild);
1146
+ }
1147
+ img.src = result.imageData;
1148
+ }
1149
+ }
1150
+ } catch (e) {
1151
+ const thumb = document.querySelector(`.frame-thumb[data-index="${i}"]`);
1152
+ if (thumb) thumb.classList.remove('processing');
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ // Export functions
1158
+ async function exportSelectedFrame() {
1159
+ const opts = getOptions();
1160
+ opts.exportMode = 'frame';
1161
+ opts.frameIndex = selectedFrameIndex;
1162
+ await runExport(opts, `Exporting frame ${selectedFrameIndex + 1}...`);
1163
+ }
1164
+
1165
+ async function exportAllFrames() {
1166
+ const opts = getOptions();
1167
+ opts.exportMode = 'all-frames';
1168
+ await runExport(opts, 'Exporting all frames...');
1169
+ }
1170
+
1171
+ async function exportAsGif() {
1172
+ const opts = getOptions();
1173
+ opts.exportMode = 'gif';
1174
+ await runExport(opts, 'Processing and exporting GIF...');
1175
+ }
1176
+
1177
+ async function runExport(opts, message) {
1178
+ $('M').classList.add('hide');
1179
+ $('B').classList.add('hide');
1180
+ $('L').classList.add('on');
1181
+ $('G').classList.add('on');
1182
+ $('G').innerHTML = '';
1183
+ $('lT').textContent = message;
1184
+
1185
+ try {
1186
+ const result = await postJson('/api/process', opts);
1187
+ if (result.logs) {
1188
+ result.logs.forEach(l => log('G', l.type[0], l.message));
1189
+ }
1190
+ showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1191
+ } catch (e) {
1192
+ log('G', 'e', e.message);
1193
+ showDone(false, 'Error', e.message);
1194
+ }
1195
+ }
1196
+
1197
+ // ── Store Assets Preset Management ──
1198
+
1199
+ // Handle preset selection change
1200
+ function onPresetChange() {
1201
+ const sel = $('sp-preset');
1202
+ const preset = presets.find(p => p.id === sel.value);
1203
+ console.log('Selected preset:', sel.value, preset);
1204
+ if (preset && preset.icons && preset.icons.length > 0) {
1205
+ currentDimensions = preset.icons.map(i => ({ width: i.width, height: i.height, filename: i.filename }));
1206
+ $('sp-name').value = preset.name;
1207
+ console.log('Loaded dimensions:', currentDimensions);
1208
+ } else {
1209
+ currentDimensions = [];
1210
+ $('sp-name').value = sel.value ? (preset?.name || '') : '';
1211
+ }
1212
+ // Hide delete button for unsaved custom presets
1213
+ document.querySelector('.btn-del').style.display = sel.value ? '' : 'none';
1214
+ renderDimensions();
1215
+ }
1216
+
1217
+ // Render dimension chips
1218
+ function renderDimensions() {
1219
+ const container = $('sp-dims');
1220
+ container.innerHTML = currentDimensions.map((d, i) =>
1221
+ `<div class="dim-item"><span>${d.width}</span><span class="dim-x">×</span><span>${d.height}</span><span class="dim-rm" onclick="removeDimension(${i})">×</span></div>`
1222
+ ).join('');
1223
+ }
1224
+
1225
+ // Add a new dimension
1226
+ function addDimension() {
1227
+ const w = +$('sp-newW').value;
1228
+ const h = +$('sp-newH').value;
1229
+ console.log('Adding dimension:', w, h);
1230
+ if (w > 0 && h > 0) {
1231
+ // Avoid duplicates
1232
+ if (!currentDimensions.some(d => d.width === w && d.height === h)) {
1233
+ currentDimensions.push({ width: w, height: h, filename: `${w}x${h}.png` });
1234
+ console.log('Current dimensions:', currentDimensions);
1235
+ renderDimensions();
1236
+ }
1237
+ $('sp-newW').value = '';
1238
+ $('sp-newH').value = '';
1239
+ } else {
1240
+ console.log('Invalid dimensions - w or h not > 0');
1241
+ }
1242
+ }
1243
+
1244
+ // Remove a dimension
1245
+ function removeDimension(idx) {
1246
+ currentDimensions.splice(idx, 1);
1247
+ renderDimensions();
1248
+ }
1249
+
1250
+ // Save current dimensions as preset
1251
+ async function saveCurrentPreset() {
1252
+ console.log('Saving preset, currentDimensions:', currentDimensions);
1253
+ const name = $('sp-name').value.trim();
1254
+ if (!name) {
1255
+ showAlert('Please enter a preset name');
1256
+ return;
1257
+ }
1258
+ if (currentDimensions.length === 0) {
1259
+ showAlert('Please add at least one dimension');
1260
+ return;
1261
+ }
1262
+
1263
+ // Use selected preset ID if editing, otherwise generate from name
1264
+ const selectedId = $('sp-preset').value;
1265
+ const id = selectedId || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1266
+ const preset = {
1267
+ id,
1268
+ name,
1269
+ description: 'Custom preset',
1270
+ icons: currentDimensions.map(d => ({
1271
+ filename: d.filename || `${d.width}x${d.height}.png`,
1272
+ width: d.width,
1273
+ height: d.height
1274
+ }))
1275
+ };
1276
+
1277
+ const btn = document.querySelector('.preset-actions .btn-sm');
1278
+ btn.disabled = true;
1279
+ btn.textContent = 'Saving...';
1280
+
1281
+ try {
1282
+ const result = await postJson('/api/save-preset', preset);
1283
+ if (result.success) {
1284
+ // Update presets list and select the new one
1285
+ const existing = presets.findIndex(p => p.id === id);
1286
+ if (existing >= 0) {
1287
+ presets[existing] = preset;
1288
+ } else {
1289
+ presets.push(preset);
1290
+ const opt = document.createElement('option');
1291
+ opt.value = id;
1292
+ opt.textContent = name;
1293
+ $('sp-preset').appendChild(opt);
1294
+ }
1295
+ $('sp-preset').value = id;
1296
+ // Show delete button now that preset is saved
1297
+ document.querySelector('.btn-del').style.display = '';
1298
+
1299
+ // Show success feedback
1300
+ btn.textContent = 'Saved!';
1301
+ btn.style.borderColor = '#4c6';
1302
+ btn.style.color = '#4c6';
1303
+ setTimeout(() => {
1304
+ btn.textContent = 'Save Preset';
1305
+ btn.style.borderColor = '';
1306
+ btn.style.color = '';
1307
+ btn.disabled = false;
1308
+ }, 1500);
1309
+ } else {
1310
+ btn.textContent = 'Failed';
1311
+ btn.style.borderColor = '#f66';
1312
+ btn.style.color = '#f66';
1313
+ setTimeout(() => {
1314
+ btn.textContent = 'Save Preset';
1315
+ btn.style.borderColor = '';
1316
+ btn.style.color = '';
1317
+ btn.disabled = false;
1318
+ }, 1500);
1319
+ }
1320
+ } catch (e) {
1321
+ btn.textContent = 'Error';
1322
+ btn.style.borderColor = '#f66';
1323
+ btn.style.color = '#f66';
1324
+ setTimeout(() => {
1325
+ btn.textContent = 'Save Preset';
1326
+ btn.style.borderColor = '';
1327
+ btn.style.color = '';
1328
+ btn.disabled = false;
1329
+ }, 1500);
1330
+ }
1331
+ }
1332
+
1333
+ // Delete current preset
1334
+ function deleteCurrentPreset() {
1335
+ const selectedId = $('sp-preset').value;
1336
+ if (!selectedId) {
1337
+ showAlert('No preset selected to delete');
1338
+ return;
1339
+ }
1340
+
1341
+ const preset = presets.find(p => p.id === selectedId);
1342
+ const presetName = preset?.name || selectedId;
1343
+
1344
+ showConfirm(`Delete preset "${presetName}"?\n\nThis cannot be undone.`, async () => {
1345
+ try {
1346
+ const result = await postJson('/api/delete-preset', { id: selectedId });
1347
+ if (result.success) {
1348
+ // Remove from local presets array
1349
+ const idx = presets.findIndex(p => p.id === selectedId);
1350
+ if (idx >= 0) presets.splice(idx, 1);
1351
+
1352
+ // Remove from dropdown and reset
1353
+ const sel = $('sp-preset');
1354
+ const opt = sel.querySelector(`option[value="${selectedId}"]`);
1355
+ if (opt) opt.remove();
1356
+ sel.value = '';
1357
+ onPresetChange();
1358
+
1359
+ showAlert('Preset deleted');
1360
+ } else {
1361
+ showAlert('Failed to delete: ' + (result.error || 'Unknown error'));
1362
+ }
1363
+ } catch (e) {
1364
+ showAlert('Failed to delete: ' + e.message);
1365
+ }
1366
+ });
1367
+ }
1368
+
1369
+ // Apply all selected tools
1370
+ async function apply() {
1371
+ if (activeTools.size === 0) return;
1372
+
1373
+ $('M').classList.add('hide');
1374
+ $('B').classList.add('hide');
1375
+ $('L').classList.add('on');
1376
+ $('G').classList.add('on');
1377
+ $('G').innerHTML = '';
1378
+
1379
+ const toolNames = Array.from(activeTools).map(t => {
1380
+ const names = { removebg: 'Remove BG', scale: 'Scale', icons: 'Icons', storepack: 'Store Assets' };
1381
+ return names[t] || t;
1382
+ });
1383
+ $('lT').textContent = toolNames.join(' → ') + '...';
1384
+
1385
+ try {
1386
+ const result = await postJson('/api/process', getOptions());
1387
+
1388
+ if (result.logs) {
1389
+ result.logs.forEach(l => log('G', l.type[0], l.message));
1390
+ }
1391
+
1392
+ showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1393
+ } catch (e) {
1394
+ log('G', 'e', e.message);
1395
+ showDone(false, 'Error', e.message);
1396
+ }
1397
+ }
1398
+
1399
+ // Reset to main view
1400
+ function reset() {
1401
+ $('D').classList.remove('on', 'ok', 'err');
1402
+ $('G').classList.remove('on');
1403
+ $('M').classList.remove('hide');
1404
+ $('B').classList.remove('hide');
1405
+ $('openFolderBtn').style.display = 'none';
1406
+ lastOutputSuccess = false;
1407
+ }
1408
+
1409
+ // Close with unsaved changes warning
1410
+ function tryClose() {
1411
+ if (hasChanges && activeTools.size > 0) {
1412
+ showConfirm('You have unsaved changes. Close anyway?', () => PicLet.close(), 'Close');
1413
+ } else {
1414
+ PicLet.close();
1415
+ }
1416
+ }
1417
+
1418
+ // Open output folder in Explorer
1419
+ function openOutputFolder() {
1420
+ postJson('/api/open-folder', {});
1421
+ }
1422
+
1423
+ // Show done state with optional folder button
1424
+ function showDone(success, title, message) {
1425
+ $('L').classList.remove('on');
1426
+ $('D').classList.add('on', success ? 'ok' : 'err');
1427
+ $('dT').textContent = title;
1428
+ $('dM').textContent = message;
1429
+ lastOutputSuccess = success;
1430
+ $('openFolderBtn').style.display = success ? '' : 'none';
1431
+ if (success) hasChanges = false;
1432
+ }
1433
+
1434
+ // Init
1435
+ setupDragDrop();
1436
+ setupDivider();
1437
+
1438
+ // Resizable dividers
1439
+ function setupDivider() {
1440
+ const divider = $('divider');
1441
+ const dividerLeft = $('dividerLeft');
1442
+ const toolsPanel = document.querySelector('.tools-panel');
1443
+ const framePanel = $('framePanel');
1444
+ const main = $('M');
1445
+ let isDraggingRight = false;
1446
+ let isDraggingLeft = false;
1447
+
1448
+ // Right divider (tools panel)
1449
+ divider.addEventListener('mousedown', (e) => {
1450
+ isDraggingRight = true;
1451
+ divider.classList.add('dragging');
1452
+ document.body.style.cursor = 'col-resize';
1453
+ document.body.style.userSelect = 'none';
1454
+ e.preventDefault();
1455
+ });
1456
+
1457
+ // Left divider (frame panel)
1458
+ dividerLeft.addEventListener('mousedown', (e) => {
1459
+ isDraggingLeft = true;
1460
+ dividerLeft.classList.add('dragging');
1461
+ document.body.style.cursor = 'col-resize';
1462
+ document.body.style.userSelect = 'none';
1463
+ e.preventDefault();
1464
+ });
1465
+
1466
+ document.addEventListener('mousemove', (e) => {
1467
+ const mainRect = main.getBoundingClientRect();
1468
+
1469
+ if (isDraggingRight) {
1470
+ // Tools width = distance from cursor to right edge
1471
+ const toolsWidth = mainRect.right - e.clientX;
1472
+ // Clamp: min 240px for tools, leave at least 150px for preview
1473
+ const maxTools = mainRect.width - 150;
1474
+ const clampedWidth = Math.max(240, Math.min(maxTools, toolsWidth));
1475
+ toolsPanel.style.width = clampedWidth + 'px';
1476
+ }
1477
+
1478
+ if (isDraggingLeft) {
1479
+ // Frame panel width = distance from left edge to cursor
1480
+ const frameWidth = e.clientX - mainRect.left;
1481
+ // Clamp: min 60px, max 150px for frame panel
1482
+ const clampedWidth = Math.max(60, Math.min(150, frameWidth));
1483
+ framePanel.style.width = clampedWidth + 'px';
1484
+ }
1485
+ });
1486
+
1487
+ document.addEventListener('mouseup', () => {
1488
+ if (isDraggingRight) {
1489
+ isDraggingRight = false;
1490
+ divider.classList.remove('dragging');
1491
+ document.body.style.cursor = '';
1492
+ document.body.style.userSelect = '';
1493
+ }
1494
+ if (isDraggingLeft) {
1495
+ isDraggingLeft = false;
1496
+ dividerLeft.classList.remove('dragging');
1497
+ document.body.style.cursor = '';
1498
+ document.body.style.userSelect = '';
1499
+ }
1500
+ });
1501
+ }
1502
+
1503
+ fetchJson('/api/info').then(d => {
1504
+ imageInfo = d;
1505
+ updateImageInfo();
1506
+
1507
+ // Load presets for store pack (full preset data)
1508
+ const presetData = d.defaults?.presets || d.presets || [];
1509
+ console.log('Loaded presets:', presetData);
1510
+ if (presetData.length > 0) {
1511
+ presets = presetData;
1512
+ const sel = $('sp-preset');
1513
+ sel.innerHTML = '<option value="">Custom...</option>';
1514
+ presets.forEach(p => {
1515
+ const opt = document.createElement('option');
1516
+ opt.value = p.id;
1517
+ opt.textContent = p.name;
1518
+ sel.appendChild(opt);
1519
+ });
1520
+ }
1521
+ // Hide delete button initially (Custom... selected)
1522
+ document.querySelector('.btn-del').style.display = 'none';
1523
+
1524
+ // Show original image immediately
1525
+ showOriginal();
1526
+ });
1527
+ </script>
1528
+ </body>
1529
+ </html>