/* ═══════════════════════════════════════════════════════════ CSG Image Upload Tool — script.js ═══════════════════════════════════════════════════════════ */ // ── State ───────────────────────────────────────────────── const state = { files: [], // File[] processed: [], // { file, originalUrl, blob, processedUrl, sku, productId, productName, mediaId, status } watermarkImg: null, // HTMLImageElement }; // ── Settings ─────────────────────────────────────────────── const DEFAULTS = { siteUrl: 'https://collisionstoneguard.com', ck: 'ck_a989ecc7f7009fe156ce83455eca4ef5b05bfd7a', cs: 'cs_32e2c692b2c53ff8722eebde75c27190337c6118', wmOpacity: 30, wmSize: 25, bgThresh: 25, quality: 75, maxDimension: 1500, removeBg: true, dropShadow: true, watermark: true, wmDataUrl: null, wpUser: '', wpPass: '', shadowIntensity: 22, solidBlack: false, }; function loadSettings() { try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem('csg_settings') || '{}') }; } catch { return { ...DEFAULTS }; } } function saveSettings(s) { localStorage.setItem('csg_settings', JSON.stringify(s)); } let cfg = loadSettings(); // ── DOM refs ─────────────────────────────────────────────── const $ = id => document.getElementById(id); const stepPanels = document.querySelectorAll('.step-panel'); const stepDots = document.querySelectorAll('#steps-nav .step'); const connectors = document.querySelectorAll('.step-connector'); // ── Navigation ───────────────────────────────────────────── function goStep(n) { stepPanels.forEach(p => p.classList.remove('active')); stepDots.forEach((d, i) => { d.classList.toggle('active', i + 1 === n); d.classList.toggle('done', i + 1 < n); }); connectors.forEach((c, i) => c.classList.toggle('done', i + 1 < n)); $(`step-${n}`).classList.add('active'); window.scrollTo({ top: 0, behavior: 'smooth' }); } // ── Step 1: Import ───────────────────────────────────────── $('btn-browse-files').onclick = () => $('input-files').click(); $('btn-browse-folder').onclick = () => $('input-folder').click(); $('input-files').onchange = e => handleFiles(e.target.files); $('input-folder').onchange = e => handleFiles(e.target.files); const dropZone = $('drop-zone'); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleFiles(e.dataTransfer.files); }); function handleFiles(fileList) { const imgs = Array.from(fileList).filter(f => f.type.startsWith('image/')); if (!imgs.length) return; state.files = imgs; renderImportPreview(); } function renderImportPreview() { const grid = $('import-grid'); grid.innerHTML = ''; state.files.forEach(f => { const div = document.createElement('div'); div.className = 'thumb-item'; const img = document.createElement('img'); img.src = URL.createObjectURL(f); const name = document.createElement('div'); name.className = 'thumb-name'; name.textContent = f.name; div.append(img, name); grid.appendChild(div); }); $('import-count').textContent = `${state.files.length} image${state.files.length !== 1 ? 's' : ''} selected`; $('import-preview').classList.remove('hidden'); dropZone.style.display = 'none'; } $('btn-clear-import').onclick = () => { state.files = []; $('import-preview').classList.add('hidden'); dropZone.style.display = ''; $('input-files').value = ''; $('input-folder').value = ''; }; $('btn-to-process').onclick = () => { buildProcessGrid(); goStep(2); }; // ── Step 2: Process ──────────────────────────────────────── $('quality-slider').oninput = e => $('quality-display').textContent = e.target.value; $('bg-tol-slider').oninput = e => $('bg-tol-display').textContent = e.target.value; $('btn-process-all').onclick = () => processAll(); $('btn-back-to-1').onclick = () => goStep(1); $('btn-to-upload').onclick = () => { buildUploadGrid(); goStep(3); }; function buildProcessGrid() { const grid = $('process-grid'); grid.innerHTML = ''; state.processed = state.files.map(f => ({ file: f, originalUrl: URL.createObjectURL(f), blob: null, processedUrl: null, sku: '', productId: null, productName: null, mediaId: null, status: 'pending' })); state.processed.forEach((item, i) => { const card = document.createElement('div'); card.className = 'process-card'; card.id = `pcard-${i}`; card.innerHTML = `
Original
Processed
${item.file.name}
Ready
`; grid.appendChild(card); }); } async function processAll() { for (let i = 0; i < state.processed.length; i++) await processSingle(i); } async function processSingle(i) { const item = state.processed[i]; setStatus(i, 'processing', 'Processing…'); try { const quality = parseInt($('quality-slider').value) / 100; const removeBg = $('toggle-bg').checked; const shadow = $('toggle-shadow').checked; const wm = $('toggle-watermark').checked; const crop = $('toggle-crop').checked; const solidBlack = item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false); cfg.bgThresh = parseInt($('bg-tol-slider').value); const nudge = (item._partX !== undefined && item._partY !== undefined) ? { x: item._partX, y: item._partY } : null; item.blob = await processImage(item.file, { quality, removeBg, shadow, wm, crop, nudge, solidBlack, shadowIntensity: item._shadowIntensity !== undefined ? item._shadowIntensity : cfg.shadowIntensity, cropPadding: item._cropPadding !== undefined ? item._cropPadding : 5, manualMatte: item._manualMatte || null, manualLasso: item._manualLasso || null, manualErases: item._eraseLassos || null, wmPos: item._wmPos || null }); item.processedUrl = URL.createObjectURL(item.blob); $(`proc-${i}`).src = item.processedUrl; setStatus(i, 'done', `Done — ${(item.blob.size / 1024).toFixed(0)} KB`); } catch(e) { setStatus(i, 'error', `Error: ${e.message}`); } } function setStatus(i, cls, msg) { const dot = $(`sdot-${i}`); const txt = $(`stxt-${i}`); if (dot) { dot.className = 'status-dot ' + cls; } if (txt) txt.textContent = msg; } function downloadSingle(i) { const item = state.processed[i]; if (!item.blob) return alert('Process the image first.'); const ext = item.blob.type === 'image/webp' ? '.webp' : '.jpg'; const a = document.createElement('a'); a.href = item.processedUrl; a.download = item.file.name.replace(/\.[^.]+$/, '') + '_processed' + ext; a.click(); } // ── Image Processing Pipeline ────────────────────────────── async function processImage(file, opts) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { try { resolve(runPipeline(img, opts)); } catch(e) { reject(e); } }; img.onerror = () => reject(new Error('Failed to load image')); img.src = URL.createObjectURL(file); }); } function applyLassoMask(ctx, W, H, lassoPoints) { const temp = document.createElement('canvas'); temp.width = W; temp.height = H; const tctx = temp.getContext('2d'); tctx.beginPath(); tctx.moveTo(lassoPoints[0].x * W, lassoPoints[0].y * H); for (let i = 1; i < lassoPoints.length; i++) { tctx.lineTo(lassoPoints[i].x * W, lassoPoints[i].y * H); } tctx.closePath(); tctx.fillStyle = '#ffffff'; tctx.fill(); tctx.globalCompositeOperation = 'source-in'; tctx.drawImage(ctx.canvas, 0, 0); ctx.clearRect(0, 0, W, H); ctx.drawImage(temp, 0, 0); } function applyEraseLasso(ctx, W, H, lassoPoints) { ctx.save(); ctx.beginPath(); ctx.moveTo(lassoPoints[0].x * W, lassoPoints[0].y * H); for (let i = 1; i < lassoPoints.length; i++) { ctx.lineTo(lassoPoints[i].x * W, lassoPoints[i].y * H); } ctx.closePath(); ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.restore(); } function getProcessedCanvas(img, { removeBg, shadow, wm, crop, manualMatte, manualLasso, manualErases, nudge, shadowIntensity, solidBlack, bgThresh, cropPadding = 5, wmPos }) { const canvas = document.createElement('canvas'); const W = img.naturalWidth, H = img.naturalHeight; canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); // ── Step 1: Detect background color (either from selection boundary or corners) ── const imgData = ctx.getImageData(0, 0, W, H); const d = imgData.data; let rSum = 0, gSum = 0, bSum = 0, n = 0; if (manualLasso && manualLasso.length > 2) { // Sample along the lasso path const sampleStep = Math.max(1, Math.floor(manualLasso.length / 100)); for (let i = 0; i < manualLasso.length; i += sampleStep) { const pt = manualLasso[i]; const x = Math.max(0, Math.min(W - 1, Math.floor(pt.x * W))); const y = Math.max(0, Math.min(H - 1, Math.floor(pt.y * H))); const idx = (y * W + x) * 4; rSum += d[idx]; gSum += d[idx + 1]; bSum += d[idx + 2]; n++; } } else if (manualMatte && manualMatte.w > 0 && manualMatte.h > 0) { // Sample along the boundary of the crop box const { x: bx, y: by, w: bw, h: bh } = manualMatte; const x0 = Math.floor(bx * W), y0 = Math.floor(by * H); const x1 = Math.min(W - 1, Math.floor((bx + bw) * W)); const y1 = Math.min(H - 1, Math.floor((by + bh) * H)); // Sample top and bottom edges (every 10th pixel) for (let x = x0; x <= x1; x += 10) { const idxT = (y0 * W + x) * 4; const idxB = (y1 * W + x) * 4; rSum += d[idxT] + d[idxB]; gSum += d[idxT + 1] + d[idxB + 1]; bSum += d[idxT + 2] + d[idxB + 2]; n += 2; } // Sample left and right edges (every 10th pixel) for (let y = y0; y <= y1; y += 10) { const idxL = (y * W + x0) * 4; const idxR = (y * W + x1) * 4; rSum += d[idxL] + d[idxR]; gSum += d[idxL + 1] + d[idxR + 1]; bSum += d[idxL + 2] + d[idxR + 2]; n += 2; } } // Fallback if no selection or sample failed if (n === 0) { const sampleR = Math.max(1, Math.floor(Math.min(W, H) * 0.02)); const corners = [[0,0],[W-1,0],[0,H-1],[W-1,H-1]]; for (const [cx,cy] of corners) { for (let dy = 0; dy < sampleR; dy++) { for (let dx = 0; dx < sampleR; dx++) { const x = Math.min(W-1, cx === 0 ? dx : cx-dx); const y = Math.min(H-1, cy === 0 ? dy : cy-dy); const idx = (y*W+x)*4; rSum += d[idx]; gSum += d[idx+1]; bSum += d[idx+2]; n++; } } } } const bgColor = { r: rSum/n, g: gSum/n, b: bSum/n }; // ── Step 2: Apply manual box mask (if any) ── if (manualMatte && manualMatte.w > 0 && manualMatte.h > 0) { const { x, y, w, h } = manualMatte; const mx = x * W, my = y * H, mw = w * W, mh = h * H; const temp = document.createElement('canvas'); temp.width = W; temp.height = H; const tctx = temp.getContext('2d'); tctx.drawImage(canvas, mx, my, mw, mh, mx, my, mw, mh); ctx.clearRect(0, 0, W, H); ctx.drawImage(temp, 0, 0); } // ── Step 3: Apply manual lasso mask (if any) ── if (manualLasso && manualLasso.length > 2) { applyLassoMask(ctx, W, H, manualLasso); } // ── Step 4: Remove background ── const thresh = bgThresh !== undefined ? bgThresh : cfg.bgThresh; if (removeBg) { removeWhiteBg(ctx, W, H, thresh, bgColor, solidBlack); } // ── Step 4.5: Apply manual erase lassos (if any) ── if (manualErases && manualErases.length > 0) { for (const eraseLasso of manualErases) { if (eraseLasso.length > 2) { applyEraseLasso(ctx, W, H, eraseLasso); } } } // ── Step 5: Center & crop to content bounding box ── let workCanvas = canvas; let finalBounds = { minX: 0, minY: 0, width: W, height: H }; if (removeBg && crop) { const bounds = (typeof edState !== 'undefined' && edState.idx !== null && edState.lockedBounds) ? edState.lockedBounds : getContentBounds(ctx, W, H); if (bounds.width > 0 && bounds.height > 0) { workCanvas = cropAndCenter(canvas, bounds, nudge, cropPadding); finalBounds = bounds; } } const outW = workCanvas.width, outH = workCanvas.height; const out = document.createElement('canvas'); out.width = outW; out.height = outH; const octx = out.getContext('2d'); if (!removeBg) { octx.fillStyle = '#ffffff'; octx.fillRect(0, 0, outW, outH); } if (shadow && removeBg) { const alpha = (shadowIntensity !== undefined ? shadowIntensity : 22) / 100; octx.shadowColor = `rgba(0,0,0,${alpha})`; octx.shadowBlur = Math.round(outW * 0.02); octx.shadowOffsetX = 0; octx.shadowOffsetY = Math.round(outW * 0.01); } octx.drawImage(workCanvas, 0, 0); octx.shadowColor = 'transparent'; if (wm && state.watermarkImg) { const wmSize = Math.round(outW * (cfg.wmSize / 100)); const ratio = state.watermarkImg.naturalHeight / state.watermarkImg.naturalWidth; const wmH = Math.round(wmSize * ratio); const pos = wmPos ? { x: Math.round(wmPos.xPct * outW), y: Math.round(wmPos.yPct * outH) } : bestWatermarkPos(octx, outW, outH, wmSize, wmH); octx.globalAlpha = cfg.wmOpacity / 100; octx.drawImage(state.watermarkImg, pos.x, pos.y, wmSize, wmH); octx.globalAlpha = 1; } // ── Step 6: Scale down to maxDimension if needed ── const maxDim = cfg.maxDimension || 1500; let finalCanvas = out; if (outW > maxDim || outH > maxDim) { const scale = maxDim / Math.max(outW, outH); const scaleW = Math.round(outW * scale); const scaleH = Math.round(outH * scale); const scaled = document.createElement('canvas'); scaled.width = scaleW; scaled.height = scaleH; const sctx = scaled.getContext('2d'); if (!removeBg) { sctx.fillStyle = '#ffffff'; sctx.fillRect(0, 0, scaleW, scaleH); } sctx.drawImage(out, 0, 0, scaleW, scaleH); finalCanvas = scaled; } // Save rendering metrics back to edState if rendering in the editor viewport if (typeof edState !== 'undefined' && edState.idx !== null && edState.originalImg && img.src === edState.originalImg.src) { edState.renderBounds = finalBounds; edState.renderNudge = nudge || { x: 0, y: 0 }; edState.renderCropPadding = cropPadding; } return finalCanvas; } function runPipeline(img, opts) { const finalCanvas = getProcessedCanvas(img, opts); const mime = opts.removeBg ? 'image/webp' : 'image/jpeg'; return new Promise(res => finalCanvas.toBlob(res, mime, opts.quality)); } // Returns bounding box of all non-transparent pixels function getContentBounds(ctx, W, H) { const data = ctx.getImageData(0, 0, W, H).data; let minX = W, maxX = 0, minY = H, maxY = 0, found = false; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (data[(y * W + x) * 4 + 3] > 8) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; found = true; } } } return found ? { minX, minY, width: maxX - minX + 1, height: maxY - minY + 1 } : { minX: 0, minY: 0, width: 0, height: 0 }; } // Crops to bounds with padding, centers onto a square canvas function cropAndCenter(srcCanvas, bounds, nudge, padPct = 5) { const pad = Math.round(Math.max(bounds.width, bounds.height) * (padPct / 100)); const side = Math.max(bounds.width, bounds.height) + pad * 2; const out = document.createElement('canvas'); out.width = side; out.height = side; const ctx = out.getContext('2d'); // transparent — no fillRect let dx = Math.round((side - bounds.width) / 2); let dy = Math.round((side - bounds.height) / 2); if (nudge) { dx += nudge.x; dy += nudge.y; } ctx.drawImage( srcCanvas, bounds.minX, bounds.minY, bounds.width, bounds.height, dx, dy, bounds.width, bounds.height ); return out; } function removeWhiteBg(ctx, W, H, tolerance, bgColor, solidBlack) { const imgData = ctx.getImageData(0, 0, W, H); const d = imgData.data; const { r: bgR, g: bgG, b: bgB } = bgColor; // ── Step 1: Flood-fill from all edges using original color-distance ── const visited = new Uint8Array(W * H); const queue = new Int32Array(W * H); let head = 0, tail = 0; const isBg = pos => { const i = pos*4; const dr = d[i]-bgR, dg = d[i+1]-bgG, db = d[i+2]-bgB; return Math.sqrt(dr*dr + dg*dg + db*db) <= tolerance; }; const enqueue = pos => { if (!visited[pos] && (d[pos*4+3] === 0 || isBg(pos))) { visited[pos]=1; queue[tail++]=pos; } }; // Start flood from all edges AND any existing transparent pixels (from manual crop) for (let i = 0; i < W*H; i++) { if (d[i*4+3] === 0) enqueue(i); } for (let x = 0; x < W; x++) { enqueue(x); enqueue((H-1)*W+x); } for (let y = 0; y < H; y++) { enqueue(y*W); enqueue(y*W+W-1); } while (head < tail) { const pos = queue[head++]; d[pos*4+3] = 0; const x = pos%W, y = (pos/W)|0; if (x > 0) enqueue(pos-1); if (x < W-1) enqueue(pos+1); if (y > 0) enqueue(pos-W); if (y < H-1) enqueue(pos+W); } // ── Step 2: Run Connected Component Noise Filter to wipe out isolated shadow/crease islands ── filterNoise(d, W, H); // ── Step 3: Turn all remaining non-transparent pixels to solid black (perfect silhouette) ── if (solidBlack) { for (let i = 0; i < W * H; i++) { if (d[i*4+3] > 0) { d[i*4] = 0; // Red d[i*4+1] = 0; // Green d[i*4+2] = 0; // Blue } } } ctx.putImageData(imgData, 0, 0); } function filterNoise(d, W, H) { const visited = new Uint8Array(W * H); const queue = new Int32Array(W * H); // Dynamically scale threshold: 0.3% of total pixels, minimum of 500px const minPixels = Math.max(500, Math.round(W * H * 0.003)); for (let i = 0; i < W * H; i++) { if (d[i*4+3] === 0 || visited[i]) continue; // Start BFS to label the connected component let head = 0, tail = 0; queue[tail++] = i; visited[i] = 1; while (head < tail) { const pos = queue[head++]; const x = pos % W; const y = (pos / W) | 0; if (x > 0) { const n = pos - 1; if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } } if (x < W - 1) { const n = pos + 1; if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } } if (y > 0) { const n = pos - W; if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } } if (y < H - 1) { const n = pos + W; if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } } } // If the component size is below the threshold, erase it (make it transparent) if (tail < minPixels) { for (let j = 0; j < tail; j++) { d[queue[j]*4+3] = 0; } } } } function bestWatermarkPos(ctx, W, H, wmW, wmH) { // Tighter padding — stay close to the part const pad = Math.round(Math.min(W, H) * 0.015); const corners = [ { x: pad, y: pad }, { x: W-wmW-pad, y: pad }, { x: pad, y: H-wmH-pad }, { x: W-wmW-pad, y: H-wmH-pad }, ]; const imgData = ctx.getImageData(0, 0, W, H); const d = imgData.data; let best = corners[3], bestScore = -1; const step = 4; for (const c of corners) { let score = 0, count = 0; for (let y = c.y; y < c.y+wmH && y < H; y += step) { for (let x = c.x; x < c.x+wmW && x < W; x += step) { const idx = (y*W+x)*4; // Strongly prefer transparent (removed BG) areas score += d[idx+3] === 0 ? 255 : (d[idx]+d[idx+1]+d[idx+2])/3; count++; } } const avg = count ? score/count : 0; if (avg > bestScore) { bestScore = avg; best = c; } } return best; } // ── Step 3: Upload ───────────────────────────────────────── $('btn-back-to-2').onclick = () => goStep(2); $('btn-upload-all').onclick = async () => { for (let i = 0; i < state.processed.length; i++) { const item = state.processed[i]; if (item.productId && item.blob) await uploadItem(i); } }; function buildUploadGrid() { const grid = $('upload-grid'); grid.innerHTML = ''; $('upload-log').classList.remove('hidden'); $('log-entries').innerHTML = ''; state.processed.forEach((item, i) => { const row = document.createElement('div'); row.className = 'upload-row'; row.id = `urow-${i}`; const thumb = item.processedUrl || item.originalUrl; row.innerHTML = `
${item.file.name}
`; grid.appendChild(row); // Auto-populate SKU from filename (everything before the extension) const fileNameSku = item.file.name.replace(/\.[^.]+$/, ''); item.sku = fileNameSku; const inp = row.querySelector(`#sku-${i}`); if (inp) { inp.value = item.sku; // Auto-trigger search so the user doesn't have to click "Find" setTimeout(() => searchSku(i), 100); } }); } async function searchSku(i) { const sku = $(`sku-${i}`).value.trim(); if (!sku) return; state.processed[i].sku = sku; const matchDiv = $(`match-${i}`); matchDiv.style.display = 'block'; matchDiv.className = 'product-match'; matchDiv.textContent = 'Searching…'; try { // 1. Try exact SKU match first let res = await apiGet(`/wp-json/wc/v3/products?sku=${encodeURIComponent(sku)}`); // 2. If no exact match, try a general search (finds partials or variations) if (res.length === 0) { addLog('info', `[Search] No exact SKU match for "${sku}". Trying general search…`); res = await apiGet(`/wp-json/wc/v3/products?search=${encodeURIComponent(sku)}`); } if (res.length === 0) { matchDiv.className = 'product-nomatch'; matchDiv.textContent = '✕ No product found'; state.processed[i].productId = null; addLog('error', `[Search] No results found for "${sku}" via SKU or Search filters.`); } else { // Pick the first result const product = res[0]; state.processed[i].productId = product.id; state.processed[i].productName = product.name; matchDiv.className = 'product-match'; matchDiv.textContent = `✓ ${product.name} (ID: ${product.id})`; addLog('success', `[Search] Found: ${product.name} (SKU: ${product.sku || 'N/A'})`); } } catch(e) { matchDiv.className = 'product-nomatch'; matchDiv.textContent = `✕ Error: ${e.message}`; addLog('error', `[Search] API Error: ${e.message}`); } } async function uploadItem(i) { const item = state.processed[i]; if (!item.blob && !state.processed[i].file) { alert('Process the image first.'); return; } if (!item.productId) { alert('Find a product SKU first.'); return; } const blob = item.blob || await processImage(item.file, { quality: cfg.quality/100, removeBg: cfg.removeBg, shadow: cfg.dropShadow, wm: cfg.watermark, crop: $('toggle-crop').checked, manualMatte: item._manualMatte || null, manualLasso: item._manualLasso || null, manualErases: item._eraseLassos || null, nudge: (item._partX !== undefined && item._partY !== undefined) ? { x: item._partX, y: item._partY } : null, cropPadding: item._cropPadding !== undefined ? item._cropPadding : 5, shadowIntensity: item._shadowIntensity !== undefined ? item._shadowIntensity : cfg.shadowIntensity, solidBlack: item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false), wmPos: item._wmPos || null }); setUploadStatus(i, 'Uploading to Media Library…', 30); addLog('info', `[${item.file.name}] Uploading to WordPress Media Library…`); try { const isWebP = blob.type === 'image/webp'; const ext = isWebP ? '.webp' : '.jpg'; const mime = isWebP ? 'image/webp' : 'image/jpeg'; const fname = item.file.name.replace(/\.[^.]+$/, '') + ext; // Determine auth for Media Library (Core WP) const mediaAuth = (cfg.wpUser && cfg.wpPass) ? 'Basic ' + btoa(cfg.wpUser + ':' + cfg.wpPass) : 'Basic ' + btoa(cfg.ck + ':' + cfg.cs); const mediaRes = await fetch(`${cfg.siteUrl}/wp-json/wp/v2/media`, { method: 'POST', headers: { 'Authorization': mediaAuth, 'Content-Disposition': `attachment; filename="${fname}"`, 'Content-Type': mime, }, body: blob, }); if (!mediaRes.ok) { const t = await mediaRes.text(); throw new Error(`Media upload failed: ${mediaRes.status} — ${t.slice(0,120)}`); } const mediaData = await mediaRes.json(); item.mediaId = mediaData.id; addLog('success', `[${item.file.name}] Media uploaded — ID: ${item.mediaId}`); setUploadStatus(i, 'Linking to product…', 65); const productRes = await fetch(`${cfg.siteUrl}/wp-json/wc/v3/products/${item.productId}`, { method: 'PUT', headers: { 'Authorization': 'Basic ' + btoa(cfg.ck + ':' + cfg.cs), 'Content-Type': 'application/json', }, body: JSON.stringify({ images: [{ id: item.mediaId }] }), }); if (!productRes.ok) { const t = await productRes.text(); throw new Error(`Product update failed: ${productRes.status} — ${t.slice(0,120)}`); } setUploadStatus(i, '✓ Done!', 100); $(`urow-${i}`).style.borderColor = 'var(--success)'; addLog('success', `[${item.file.name}] ✓ Featured image set on "${item.productName}"`); } catch(e) { setUploadStatus(i, `✕ ${e.message}`, 0); $(`urow-${i}`).style.borderColor = 'var(--danger)'; addLog('error', `[${item.file.name}] ✕ ${e.message}`); if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) { addLog('error', '⚠ Possible CORS issue. Try running this tool via: npx serve . (then open http://localhost:3000)'); } } } function setUploadStatus(i, msg, pct) { const s = $(`ustatus-${i}`); const b = $(`uprog-${i}`); if (s) s.textContent = msg; if (b) b.style.width = pct + '%'; } function addLog(type, msg) { const el = document.createElement('div'); el.className = `log-entry ${type}`; el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; const log = $('log-entries'); log.prepend(el); } // ── WooCommerce API helpers ──────────────────────────────── async function apiGet(path) { const res = await fetch(`${cfg.siteUrl}${path}`, { headers: { 'Authorization': 'Basic ' + btoa(cfg.ck + ':' + cfg.cs) } }); if (!res.ok) throw new Error(`API error ${res.status}`); return res.json(); } // ── Settings Modal ───────────────────────────────────────── $('btn-settings').onclick = openSettings; $('btn-close-settings').onclick = closeSettings; $('modal-backdrop').onclick = closeSettings; function openSettings() { $('s-url').value = cfg.siteUrl; $('s-ck').value = cfg.ck; $('s-cs').value = cfg.cs; $('s-wp-user').value = cfg.wpUser || ''; $('s-wp-pass').value = cfg.wpPass || ''; $('s-wm-opacity').value = cfg.wmOpacity; $('s-wm-size').value = cfg.wmSize; $('s-bg-thresh').value = cfg.bgThresh; $('s-max-dim').value = cfg.maxDimension || 1500; $('wm-opacity-display').textContent = cfg.wmOpacity; $('wm-size-display').textContent = cfg.wmSize; $('bg-thresh-display').textContent = cfg.bgThresh; $('max-dim-display').textContent = cfg.maxDimension || 1500; if (cfg.wmDataUrl) $('wm-preview').src = cfg.wmDataUrl; $('settings-modal').classList.remove('hidden'); } function closeSettings() { $('settings-modal').classList.add('hidden'); } $('s-wm-opacity').oninput = e => $('wm-opacity-display').textContent = e.target.value; $('s-wm-size').oninput = e => $('wm-size-display').textContent = e.target.value; $('s-bg-thresh').oninput = e => $('bg-thresh-display').textContent = e.target.value; $('s-max-dim').oninput = e => $('max-dim-display').textContent = e.target.value; $('btn-save-settings').onclick = () => { cfg.siteUrl = $('s-url').value.replace(/\/$/, ''); cfg.ck = $('s-ck').value; cfg.cs = $('s-cs').value; cfg.wpUser = $('s-wp-user').value; cfg.wpPass = $('s-wp-pass').value; cfg.wmOpacity = parseInt($('s-wm-opacity').value); cfg.wmSize = parseInt($('s-wm-size').value); cfg.bgThresh = parseInt($('s-bg-thresh').value); cfg.maxDimension = parseInt($('s-max-dim').value); saveSettings(cfg); closeSettings(); }; $('btn-test-api').onclick = async () => { const status = $('api-status'); status.textContent = 'Testing…'; status.className = ''; try { const url = $('s-url').value.replace(/\/$/, ''); const ck = $('s-ck').value, cs = $('s-cs').value; const res = await fetch(`${url}/wp-json/wc/v3/products?per_page=1`, { headers: { 'Authorization': 'Basic ' + btoa(ck + ':' + cs) } }); if (res.ok) { status.textContent = '✓ Connected successfully!'; status.className = 'ok'; } else { status.textContent = `✕ HTTP ${res.status} — check credentials`; status.className = 'err'; } } catch(e) { status.textContent = `✕ ${e.message}`; status.className = 'err'; } }; // Reveal password buttons document.querySelectorAll('.btn-reveal').forEach(btn => { btn.onclick = () => { const inp = $(btn.dataset.target); inp.type = inp.type === 'password' ? 'text' : 'password'; }; }); // Watermark image loader $('btn-load-wm').onclick = () => $('input-wm').click(); $('input-wm').onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { cfg.wmDataUrl = ev.target.result; $('wm-preview').src = cfg.wmDataUrl; loadWatermarkImg(cfg.wmDataUrl); }; reader.readAsDataURL(file); }; function loadWatermarkImg(src) { const img = new Image(); img.onload = () => { state.watermarkImg = img; }; img.src = src; } // ── Init ─────────────────────────────────────────────────── function init() { // Load watermark: try saved data URL first, then watermark.png if (cfg.wmDataUrl) { loadWatermarkImg(cfg.wmDataUrl); } else { const img = new Image(); img.onload = () => { state.watermarkImg = img; }; img.onerror = () => { /* watermark.png not found — user can set in settings */ }; img.src = 'watermark.png'; } initTooltips(); } // ── Custom Tooltips ──────────────────────────────────────── function initTooltips() { const tt = document.createElement('div'); tt.className = 'custom-tooltip'; document.body.appendChild(tt); let timer; const show = (el) => { const text = el.getAttribute('title'); if (!text) return; el.dataset.title = text; el.removeAttribute('title'); // Prevent default tooltip timer = setTimeout(() => { tt.textContent = text; tt.classList.add('visible'); const rect = el.getBoundingClientRect(); let top = rect.top - tt.offsetHeight - 10; tt.className = 'custom-tooltip visible pos-top'; if (top < 10) { // Flip to bottom if no space on top top = rect.bottom + 10; tt.className = 'custom-tooltip visible pos-bottom'; } tt.style.top = top + 'px'; tt.style.left = (rect.left + rect.width/2 - tt.offsetWidth/2) + 'px'; }, 600); }; const hide = (el) => { clearTimeout(timer); tt.classList.remove('visible'); if (el.dataset.title) el.setAttribute('title', el.dataset.title); }; document.body.addEventListener('mouseover', e => { const el = e.target.closest('[title]'); if (el) show(el); }, true); document.body.addEventListener('mouseout', e => { const el = e.target.closest('[data-title]'); if (el) hide(el); }, true); } init(); $('btn-start-over').onclick = () => { if (!confirm('Are you sure you want to start over? All imported and processed images will be cleared.')) return; state.files = []; state.processed = []; $('import-preview').classList.add('hidden'); $('drop-zone').style.display = ''; $('input-files').value = ''; $('input-folder').value = ''; goStep(1); }; function mapFinalToOriginal(rx, ry, W, H, bounds, nudge, cropPadding) { const pad = Math.round(Math.max(bounds.width, bounds.height) * (cropPadding / 100)); const side = Math.max(bounds.width, bounds.height) + pad * 2; const dx = Math.round((side - bounds.width) / 2) + (nudge ? nudge.x : 0); const dy = Math.round((side - bounds.height) / 2) + (nudge ? nudge.y : 0); const xOrig = rx * side - dx + bounds.minX; const yOrig = ry * side - dy + bounds.minY; return { x: Math.max(0, Math.min(1, xOrig / W)), y: Math.max(0, Math.min(1, yOrig / H)) }; } function mapOriginalToFinal(xPct, yPct, W, H, bounds, nudge, cropPadding) { const pad = Math.round(Math.max(bounds.width, bounds.height) * (cropPadding / 100)); const side = Math.max(bounds.width, bounds.height) + pad * 2; const dx = Math.round((side - bounds.width) / 2) + (nudge ? nudge.x : 0); const dy = Math.round((side - bounds.height) / 2) + (nudge ? nudge.y : 0); const rx = (xPct * W - bounds.minX + dx) / side; const ry = (yPct * H - bounds.minY + dy) / side; return { x: rx, y: ry }; } // ── Editor Modal ─────────────────────────────────────────── const edState = { idx: null, mode: 'move', // 'move' | 'crop' | 'lasso' | 'erase' | 'nudge' wmX: 0, wmY: 0, partX: 0, partY: 0, // Nudge offsets in pixels box: null, // { x, y, w, h } as 0-1 lassoPoints: null, // Array of { x, y } as 0-1 activePath: null, // Array of { x, y } as 0-1 (current drawing lasso path) eraseLassos: [], // Array of arrays of { x, y } as 0-1 dragging: false, panning: false, zoom: 1.0, panX: 0, panY: 0, ox: 0, oy: 0, sx: 0, sy: 0, originalImg: null }; function applyZoomPan() { const container = $('ed-zoom-container'); if (container) { container.style.transform = `translate(${edState.panX}px, ${edState.panY}px) scale(${edState.zoom})`; } } function openEditor(i) { const item = state.processed[i]; edState.idx = i; edState.mode = 'move'; edState.box = item._manualMatte || null; edState.lassoPoints = item._manualLasso || null; edState.activePath = null; edState.eraseLassos = item._eraseLassos ? [...item._eraseLassos] : []; edState.zoom = 1.0; edState.panX = 0; edState.panY = 0; applyZoomPan(); // Re-attach handlers to be safe if ($('ed-tool-move')) $('ed-tool-move').onclick = () => setTool('move'); if ($('ed-tool-box')) $('ed-tool-box').onclick = () => setTool('crop'); if ($('ed-tool-crop')) $('ed-tool-crop').onclick = () => setTool('crop'); if ($('ed-tool-lasso')) $('ed-tool-lasso').onclick = () => setTool('lasso'); if ($('ed-tool-erase')) $('ed-tool-erase').onclick = () => setTool('erase'); if ($('ed-tool-nudge')) $('ed-tool-nudge').onclick = () => setTool('nudge'); if ($('ed-clear-erase')) $('ed-clear-erase').onclick = () => { edState.eraseLassos = []; drawOverlay(); edRender(); }; edState.partX = item._partX || 0; edState.partY = item._partY || 0; edState.originalImg = null; $('ed-shadow').value = item._shadowIntensity || cfg.shadowIntensity || 22; $('ed-shadow-val').textContent = $('ed-shadow').value; setTool('move'); $('editor-filename').textContent = item.file.name; $('ed-thresh').value = cfg.bgThresh; $('ed-thresh-val').textContent = cfg.bgThresh; $('ed-pad').value = item._cropPadding !== undefined ? item._cropPadding : 5; $('ed-pad-val').textContent = $('ed-pad').value; $('ed-wm-sz').value = cfg.wmSize; $('ed-wm-sz-val').textContent = cfg.wmSize; const edSolidBlack = $('ed-solid-black'); if (edSolidBlack) { edSolidBlack.checked = item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false); } $('editor-modal').classList.remove('hidden'); const loader = $('ed-loader'); if (loader) loader.classList.remove('hidden'); const img = new Image(); img.onload = () => { edState.originalImg = img; if (loader) loader.classList.add('hidden'); edRender(); }; img.src = item.originalUrl; } function edRender() { if (!edState.originalImg) return; const ec = $('ed-canvas'); if (!ec) return; const loader = $('ed-loader'); if (loader) loader.classList.remove('hidden'); const item = state.processed[edState.idx]; const removeBg = $('toggle-bg').checked; const shadow = $('toggle-shadow').checked; const crop = $('toggle-crop').checked; const wmSzPct = parseInt($('ed-wm-sz').value); const solidBlack = $('ed-solid-black') ? $('ed-solid-black').checked : false; const shadowIntensity = parseInt($('ed-shadow').value); const thresh = parseInt($('ed-thresh').value); const padPct = parseInt($('ed-pad').value); // We ALWAYS show the final store-ready layout (crops, centering, shadow, watermark, etc.) const finalCanvas = getProcessedCanvas(edState.originalImg, { removeBg, shadow, wm: false, // We render the watermark as a draggable DOM element instead of burning it in, so the user can drag it! crop, manualMatte: edState.box, manualLasso: edState.lassoPoints, manualErases: edState.eraseLassos, nudge: { x: edState.partX, y: edState.partY }, shadowIntensity, solidBlack, bgThresh: thresh, cropPadding: padPct }); ec.width = finalCanvas.width; ec.height = finalCanvas.height; const ctx = ec.getContext('2d'); ctx.clearRect(0, 0, ec.width, ec.height); ctx.drawImage(finalCanvas, 0, 0); // Position and show watermark in move/nudge modes if enabled edPositionWatermark(wmSzPct); // Update overlay size and clear it (we don't show crop/lasso overlays in move/nudge modes) updateOverlayCanvasSize(); drawOverlay(); if (loader) loader.classList.add('hidden'); } // Returns canvas position/size relative to the zoom container parent function getCanvasOffset() { const canvas = $('ed-canvas'); if (!canvas) return { left: 0, top: 0, displayW: 0, displayH: 0 }; return { left: canvas.offsetLeft, top: canvas.offsetTop, displayW: canvas.offsetWidth, displayH: canvas.offsetHeight, }; } function edPositionWatermark(wmSzPct) { if (!state.watermarkImg) return; const ec = $('ed-canvas'); const wm = $('ed-wm-img'); if (cfg.wmDataUrl) wm.src = cfg.wmDataUrl; else wm.src = state.watermarkImg.src || state.watermarkImg.currentSrc || ''; const off = getCanvasOffset(); const scale = off.displayW / ec.width || 1; const wmPx = Math.round(ec.width * (wmSzPct / 100)); const ratio = state.watermarkImg.naturalHeight / state.watermarkImg.naturalWidth; const wmHpx = Math.round(wmPx * ratio); wm.style.width = Math.round(wmPx * scale) + 'px'; wm.style.height = 'auto'; wm.style.opacity = cfg.wmOpacity / 100; wm.style.display = $('toggle-watermark').checked ? '' : 'none'; // Default: bottom-right corner of the canvas (not the wrap) if (edState.wmX === 0 && edState.wmY === 0) { edState.wmX = off.displayW - Math.round(wmPx * scale) - 10; edState.wmY = off.displayH - Math.round(wmHpx * scale) - 10; } // wmX/wmY are canvas-relative display pixels → offset by canvas position in zoom container wm.style.left = (off.left + edState.wmX) + 'px'; wm.style.top = (off.top + edState.wmY) + 'px'; } // Tool switching function setTool(mode) { edState.mode = mode; const btnMove = $('ed-tool-move'); const btnBox = $('ed-tool-box') || $('ed-tool-crop'); const btnLasso = $('ed-tool-lasso'); const btnErase = $('ed-tool-erase'); const btnNudge = $('ed-tool-nudge'); if (btnMove) btnMove.classList.toggle('active', mode === 'move'); if (btnBox) btnBox.classList.toggle('active', mode === 'crop'); if (btnLasso) btnLasso.classList.toggle('active', mode === 'lasso'); if (btnErase) btnErase.classList.toggle('active', mode === 'erase'); if (btnNudge) btnNudge.classList.toggle('active', mode === 'nudge'); const wrap = $('ed-preview-wrap'); if (wrap) { wrap.classList.toggle('mode-move', mode === 'move'); wrap.classList.toggle('mode-crop', mode === 'crop'); wrap.classList.toggle('mode-lasso', mode === 'lasso'); wrap.classList.toggle('mode-erase', mode === 'erase'); wrap.classList.toggle('mode-nudge', mode === 'nudge'); } $('ed-wm-img').style.pointerEvents = mode === 'move' ? 'auto' : 'none'; $('ed-selection-box').classList.add('hidden'); // Re-render immediately to update canvas view (raw original vs final output) edRender(); } function updateOverlayCanvasSize() { const ec = $('ed-canvas'); const oc = $('ed-overlay-canvas'); if (!ec || !oc) return; oc.width = ec.width; oc.height = ec.height; oc.style.left = ec.offsetLeft + 'px'; oc.style.top = ec.offsetTop + 'px'; oc.style.width = ec.offsetWidth + 'px'; oc.style.height = ec.offsetHeight + 'px'; } function drawOverlay() { const oc = $('ed-overlay-canvas'); if (!oc) return; const ctx = oc.getContext('2d'); ctx.clearRect(0, 0, oc.width, oc.height); const W = oc.width, H = oc.height; // Projection helper: const project = (xPct, yPct) => { if (edState.renderBounds && edState.originalImg) { const origW = edState.originalImg.naturalWidth; const origH = edState.originalImg.naturalHeight; const pt = mapOriginalToFinal(xPct, yPct, origW, origH, edState.renderBounds, edState.renderNudge, edState.renderCropPadding); return { x: pt.x * W, y: pt.y * H }; } return { x: xPct * W, y: yPct * H }; }; // 1. Draw completed Erase Lassos (Red) if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { if (edState.eraseLassos && edState.eraseLassos.length > 0) { for (const pts of edState.eraseLassos) { if (pts.length > 2) { ctx.beginPath(); const p0 = project(pts[0].x, pts[0].y); ctx.moveTo(p0.x, p0.y); for (let i = 1; i < pts.length; i++) { const p = project(pts[i].x, pts[i].y); ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.fillStyle = 'rgba(235, 87, 87, 0.2)'; ctx.fill(); ctx.strokeStyle = '#eb5757'; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]); } } } } // 2. Draw Keep Box outline (if it exists) if (edState.box) { const { x, y, w, h } = edState.box; const p1 = project(x, y); const p2 = project(x + w, y + h); ctx.strokeStyle = '#3b7dd8'; ctx.lineWidth = Math.max(2, Math.round(W * 0.002)); ctx.setLineDash([6, 4]); ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y); ctx.setLineDash([]); } // 3. Draw Keep Lasso outline (if it exists) const lassoPts = (edState.mode === 'lasso' && edState.activePath) ? edState.activePath : edState.lassoPoints; if (lassoPts && lassoPts.length > 0) { ctx.beginPath(); const p0 = project(lassoPts[0].x, lassoPts[0].y); ctx.moveTo(p0.x, p0.y); for (let i = 1; i < lassoPts.length; i++) { const p = project(lassoPts[i].x, lassoPts[i].y); ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.strokeStyle = '#3b7dd8'; ctx.lineWidth = Math.max(2, Math.round(W * 0.002)); ctx.setLineDash([6, 4]); ctx.stroke(); ctx.setLineDash([]); // Draw dots ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#3b7dd8'; ctx.lineWidth = 1; const dotRadius = Math.max(3, Math.round(W * 0.003)); const step = edState.dragging ? 15 : 1; for (let i = 0; i < lassoPts.length; i += step) { const p = project(lassoPts[i].x, lassoPts[i].y); ctx.beginPath(); ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } if ((lassoPts.length - 1) % step !== 0) { const lastIdx = lassoPts.length - 1; const p = project(lassoPts[lastIdx].x, lassoPts[lastIdx].y); ctx.beginPath(); ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } // 4. Draw Active Erase Lasso Drawing outline (Red) if (edState.mode === 'erase') { const pts = edState.activePath; if (pts && pts.length > 0) { ctx.beginPath(); const p0 = project(pts[0].x, pts[0].y); ctx.moveTo(p0.x, p0.y); for (let i = 1; i < pts.length; i++) { const p = project(pts[i].x, pts[i].y); ctx.lineTo(p.x, p.y); } ctx.strokeStyle = '#eb5757'; ctx.lineWidth = 2.5; ctx.stroke(); // Draw dots ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#eb5757'; ctx.lineWidth = 1; const dotRadius = 4; for (let i = 0; i < pts.length; i += 15) { const p = project(pts[i].x, pts[i].y); ctx.beginPath(); ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } } } function getCanvasCoords(e) { const canvas = $('ed-canvas'); if (!canvas) return { x: 0, y: 0 }; const canvRect = canvas.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const rx = (clientX - canvRect.left) / canvRect.width; const ry = (clientY - canvRect.top) / canvRect.height; // Map final canvas coordinate back to original image coordinate if we have render metrics if (edState.renderBounds && edState.originalImg) { const W = edState.originalImg.naturalWidth; const H = edState.originalImg.naturalHeight; return mapFinalToOriginal(rx, ry, W, H, edState.renderBounds, edState.renderNudge, edState.renderCropPadding); } return { x: Math.max(0, Math.min(1, rx)), y: Math.max(0, Math.min(1, ry)) }; } // Drag / Draw events const edWrap = $('ed-preview-wrap'); edWrap.addEventListener('mousedown', e => { if (e.target.id === 'ed-wm-img') return; if (e.button === 1) { // Middle mouse button (Mouse 3) e.preventDefault(); edState.panning = true; edState.panStartX = e.clientX; edState.panStartY = e.clientY; edState.panStartPanX = edState.panX; edState.panStartPanY = edState.panY; return; } if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { edState.lockedBounds = edState.renderBounds || { minX: 0, minY: 0, width: edState.originalImg.naturalWidth, height: edState.originalImg.naturalHeight }; } const coords = getCanvasCoords(e); edState.dragging = true; edState.ox = e.clientX; edState.oy = e.clientY; if (edState.mode === 'crop') { edState.sx = coords.x; edState.sy = coords.y; edState.box = { x: coords.x, y: coords.y, w: 0, h: 0 }; updateOverlayCanvasSize(); drawOverlay(); } else if (edState.mode === 'lasso' || edState.mode === 'erase') { edState.activePath = [{ x: coords.x, y: coords.y }]; updateOverlayCanvasSize(); drawOverlay(); } else if (edState.mode === 'nudge') { edState.sx = edState.partX; edState.sy = edState.partY; } }); edWrap.addEventListener('touchstart', e => { if (e.target.id === 'ed-wm-img') return; if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { edState.lockedBounds = edState.renderBounds || { minX: 0, minY: 0, width: edState.originalImg.naturalWidth, height: edState.originalImg.naturalHeight }; } const coords = getCanvasCoords(e); edState.dragging = true; edState.ox = e.touches[0].clientX; edState.oy = e.touches[0].clientY; if (edState.mode === 'crop') { edState.sx = coords.x; edState.sy = coords.y; edState.box = { x: coords.x, y: coords.y, w: 0, h: 0 }; updateOverlayCanvasSize(); drawOverlay(); } else if (edState.mode === 'lasso' || edState.mode === 'erase') { edState.activePath = [{ x: coords.x, y: coords.y }]; updateOverlayCanvasSize(); drawOverlay(); } else if (edState.mode === 'nudge') { edState.sx = edState.partX; edState.sy = edState.partY; } }); document.addEventListener('mousemove', e => { if (edState.panning) { edState.panX = edState.panStartPanX + (e.clientX - edState.panStartX); edState.panY = edState.panStartPanY + (e.clientY - edState.panStartY); applyZoomPan(); return; } if (!edState.dragging) return; if (edState.mode === 'move') { const off = getCanvasOffset(); const wm = $('ed-wm-img'); edState.wmX = Math.max(0, Math.min(off.displayW - wm.offsetWidth, edState.sx + (e.clientX - edState.ox) / edState.zoom)); edState.wmY = Math.max(0, Math.min(off.displayH - wm.offsetHeight, edState.sy + (e.clientY - edState.oy) / edState.zoom)); wm.style.left = (off.left + edState.wmX) + 'px'; wm.style.top = (off.top + edState.wmY) + 'px'; } else { const coords = getCanvasCoords(e); if (edState.mode === 'crop') { edState.box = { x: Math.min(edState.sx, coords.x), y: Math.min(edState.sy, coords.y), w: Math.abs(coords.x - edState.sx), h: Math.abs(coords.y - edState.sy) }; drawOverlay(); } else if (edState.mode === 'lasso' || edState.mode === 'erase') { const pts = edState.activePath; if (pts) { const lastPt = pts[pts.length - 1]; const dist = Math.hypot(coords.x - lastPt.x, coords.y - lastPt.y); if (dist > 0.005) { pts.push({ x: coords.x, y: coords.y }); drawOverlay(); } } } else if (edState.mode === 'nudge') { const ec = $('ed-canvas'); const off = getCanvasOffset(); const scale = ec.width / off.displayW; edState.partX = edState.sx + ((e.clientX - edState.ox) / edState.zoom) * scale; edState.partY = edState.sy + ((e.clientY - edState.oy) / edState.zoom) * scale; const tx = (e.clientX - edState.ox) / edState.zoom; const ty = (e.clientY - edState.oy) / edState.zoom; ec.style.transform = `translate(${tx}px, ${ty}px)`; } } }); document.addEventListener('touchmove', e => { if (!edState.dragging) return; const clientX = e.touches[0].clientX; const clientY = e.touches[0].clientY; if (edState.mode === 'move') { const off = getCanvasOffset(); const wm = $('ed-wm-img'); edState.wmX = Math.max(0, Math.min(off.displayW - wm.offsetWidth, edState.sx + (clientX - edState.ox) / edState.zoom)); edState.wmY = Math.max(0, Math.min(off.displayH - wm.offsetHeight, edState.sy + (clientY - edState.oy) / edState.zoom)); wm.style.left = (off.left + edState.wmX) + 'px'; wm.style.top = (off.top + edState.wmY) + 'px'; } else { const coords = getCanvasCoords(e); if (edState.mode === 'crop') { edState.box = { x: Math.min(edState.sx, coords.x), y: Math.min(edState.sy, coords.y), w: Math.abs(coords.x - edState.sx), h: Math.abs(coords.y - edState.sy) }; drawOverlay(); } else if (edState.mode === 'lasso' || edState.mode === 'erase') { const pts = edState.activePath; if (pts) { const lastPt = pts[pts.length - 1]; const dist = Math.hypot(coords.x - lastPt.x, coords.y - lastPt.y); if (dist > 0.005) { pts.push({ x: coords.x, y: coords.y }); drawOverlay(); } } } else if (edState.mode === 'nudge') { const ec = $('ed-canvas'); const off = getCanvasOffset(); const scale = ec.width / off.displayW; edState.partX = edState.sx + ((clientX - edState.ox) / edState.zoom) * scale; edState.partY = edState.sy + ((clientY - edState.oy) / edState.zoom) * scale; const tx = (clientX - edState.ox) / edState.zoom; const ty = (clientY - edState.oy) / edState.zoom; ec.style.transform = `translate(${tx}px, ${ty}px)`; } } }, { passive: true }); document.addEventListener('mouseup', () => { if (edState.panning) { edState.panning = false; return; } if (edState.dragging) { edState.lockedBounds = null; if (edState.mode === 'nudge') { $('ed-canvas').style.transform = ''; edRender(); } else if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { if (edState.mode === 'lasso') { if (edState.activePath && edState.activePath.length > 2) { edState.lassoPoints = edState.activePath; } edState.activePath = null; } else if (edState.mode === 'erase') { if (edState.activePath && edState.activePath.length > 2) { edState.eraseLassos.push(edState.activePath); } edState.activePath = null; } edRender(); } } edState.dragging = false; }); document.addEventListener('touchend', () => { if (edState.dragging) { edState.lockedBounds = null; if (edState.mode === 'nudge') { $('ed-canvas').style.transform = ''; edRender(); } else if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { if (edState.mode === 'lasso') { if (edState.activePath && edState.activePath.length > 2) { edState.lassoPoints = edState.activePath; } edState.activePath = null; } else if (edState.mode === 'erase') { if (edState.activePath && edState.activePath.length > 2) { edState.eraseLassos.push(edState.activePath); } edState.activePath = null; } edRender(); } } edState.dragging = false; }); const edWm = $('ed-wm-img'); edWm.addEventListener('mousedown', e => { if (edState.mode !== 'move') return; e.preventDefault(); edState.dragging = true; edState.ox = e.clientX; edState.oy = e.clientY; edState.sx = edState.wmX; edState.sy = edState.wmY; }); edWm.addEventListener('touchstart', e => { if (edState.mode !== 'move') return; edState.dragging = true; edState.ox = e.touches[0].clientX; edState.oy = e.touches[0].clientY; edState.sx = edState.wmX; edState.sy = edState.wmY; }); // Scroll wheel zoom on preview wrapper edWrap.addEventListener('wheel', e => { e.preventDefault(); const rect = edWrap.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Mouse coordinate relative to the zoom container before zoom level changes: const cx = (mouseX - edState.panX) / edState.zoom; const cy = (mouseY - edState.panY) / edState.zoom; const zoomFactor = 1.1; if (e.deltaY < 0) { edState.zoom = Math.min(10, edState.zoom * zoomFactor); } else { edState.zoom = Math.max(0.5, edState.zoom / zoomFactor); } // Shift pan offsets to align zoom center with cursor position: edState.panX = mouseX - cx * edState.zoom; edState.panY = mouseY - cy * edState.zoom; applyZoomPan(); }, { passive: false }); window.addEventListener('resize', () => { if (!$('editor-modal').classList.contains('hidden')) { updateOverlayCanvasSize(); drawOverlay(); const wmSzPct = parseInt($('ed-wm-sz').value); edPositionWatermark(wmSzPct); } }); // Sidebar slider live updates $('ed-thresh').oninput = e => { $('ed-thresh-val').textContent = e.target.value; edRender(); }; $('ed-pad').oninput = e => { $('ed-pad-val').textContent = e.target.value; edRender(); }; $('ed-wm-sz').oninput = e => { $('ed-wm-sz-val').textContent = e.target.value; edPositionWatermark(parseInt(e.target.value)); }; $('ed-shadow').oninput = e => { $('ed-shadow-val').textContent = e.target.value; edRender(); }; $('ed-white-bg').onchange = e => $('ed-preview-wrap').classList.toggle('preview-white', e.target.checked); const edSolidBlack = $('ed-solid-black'); if (edSolidBlack) { edSolidBlack.onchange = e => { edRender(); }; } $('ed-reset').onclick = () => { if (!confirm('Are you sure you want to discard all manual changes for this image?')) return; edState.wmX = 0; edState.wmY = 0; edState.partX = 0; edState.partY = 0; edState.box = null; edState.lassoPoints = null; edState.eraseLassos = []; edState.zoom = 1.0; edState.panX = 0; edState.panY = 0; applyZoomPan(); $('ed-thresh').value = cfg.bgThresh; $('ed-thresh-val').textContent = cfg.bgThresh; $('ed-pad').value = 5; $('ed-pad-val').textContent = 5; $('ed-wm-sz').value = cfg.wmSize; $('ed-wm-sz-val').textContent = cfg.wmSize; $('ed-shadow').value = cfg.shadowIntensity || 22; $('ed-shadow-val').textContent = $('ed-shadow').value; const edSolidBlack = $('ed-solid-black'); if (edSolidBlack) { edSolidBlack.checked = cfg.solidBlack || false; } edRender(); }; $('editor-backdrop').onclick = () => $('editor-modal').classList.add('hidden'); $('ed-close-btn').onclick = () => $('editor-modal').classList.add('hidden'); $('ed-apply').onclick = async () => { const item = state.processed[edState.idx]; const thresh = parseInt($('ed-thresh').value); const wmSzPct = parseInt($('ed-wm-sz').value); const quality = parseInt($('quality-slider').value) / 100; const removeBg = $('toggle-bg').checked; const shadow = $('toggle-shadow').checked; const crop = $('toggle-crop').checked; const wm = $('toggle-watermark').checked; const shadowIntensity = parseInt($('ed-shadow').value); const solidBlack = $('ed-solid-black') ? $('ed-solid-black').checked : false; const padPct = parseInt($('ed-pad').value); // Convert canvas-relative display pixels → percentage of canvas size (0 to 1) const off = getCanvasOffset(); const wmXpct = edState.wmX / off.displayW; const wmYpct = edState.wmY / off.displayH; const savedThresh = cfg.bgThresh; const savedWmSize = cfg.wmSize; cfg.bgThresh = thresh; cfg.wmSize = wmSzPct; item._wmPos = { xPct: wmXpct, yPct: wmYpct }; item._manualMatte = edState.box; item._manualLasso = edState.lassoPoints; item._eraseLassos = edState.eraseLassos; item._partX = edState.partX; item._partY = edState.partY; item._shadowIntensity = shadowIntensity; item._solidBlack = solidBlack; item._cropPadding = padPct; item.blob = await processImage(item.file, { quality, removeBg, shadow, wm, crop, manualMatte: edState.box, manualLasso: edState.lassoPoints, manualErases: edState.eraseLassos, nudge: { x: edState.partX, y: edState.partY }, shadowIntensity, solidBlack, cropPadding: padPct, wmPos: item._wmPos }); cfg.bgThresh = savedThresh; cfg.wmSize = savedWmSize; item.processedUrl = URL.createObjectURL(item.blob); $(`proc-${edState.idx}`).src = item.processedUrl; setStatus(edState.idx, 'done', `Edited — ${(item.blob.size/1024).toFixed(0)} KB`); $('editor-modal').classList.add('hidden'); }; // Keyboard navigation for editor modal document.addEventListener('keydown', e => { if ($('editor-modal').classList.contains('hidden')) return; if (e.key === 'Enter') { if (e.target.tagName === 'INPUT' && e.target.type === 'text') return; e.preventDefault(); if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { setTool('move'); } else { $('ed-apply').click(); } } else if (e.key === 'Escape') { e.preventDefault(); $('ed-close-btn').click(); } });