/* ═══════════════════════════════════════════════════════════
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}
`;
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 = `
`;
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();
}
});