1722 lines
61 KiB
JavaScript
1722 lines
61 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
|
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 = `
|
|
<div class="process-card-images">
|
|
<div class="img-pane"><span class="pane-label">Original</span><img id="orig-${i}" src="${item.originalUrl}"></div>
|
|
<div class="img-divider"></div>
|
|
<div class="img-pane"><span class="pane-label">Processed</span><img id="proc-${i}" src="${item.originalUrl}"></div>
|
|
</div>
|
|
<div class="process-card-body">
|
|
<div class="process-card-name">${item.file.name}</div>
|
|
<div class="process-card-actions">
|
|
<button class="btn-secondary btn-sm" onclick="processSingle(${i})">Process</button>
|
|
<button class="btn-secondary btn-sm" onclick="openEditor(${i})">✏ Edit</button>
|
|
<button class="btn-ghost btn-sm" onclick="downloadSingle(${i})">↓ Save</button>
|
|
</div>
|
|
<div class="process-status"><div class="status-dot" id="sdot-${i}"></div><span id="stxt-${i}">Ready</span></div>
|
|
</div>`;
|
|
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 = `
|
|
<img src="${thumb}" id="uthumb-${i}">
|
|
<div class="upload-row-info"><div class="filename">${item.file.name}</div></div>
|
|
<div class="sku-input-wrap">
|
|
<label>Product SKU</label>
|
|
<div class="sku-search-row">
|
|
<input type="text" class="sku-input" id="sku-${i}" placeholder="e.g. WK123-456" value="${item.sku}">
|
|
<button class="btn-secondary btn-sm" onclick="searchSku(${i})">Find</button>
|
|
</div>
|
|
<div id="match-${i}" class="product-match" style="display:none"></div>
|
|
</div>
|
|
<div class="upload-row-status">
|
|
<div class="upload-status-text" id="ustatus-${i}">—</div>
|
|
<div class="upload-progress"><div class="upload-progress-bar" id="uprog-${i}" style="width:0%"></div></div>
|
|
</div>
|
|
<button class="btn-primary btn-sm" id="ubtn-${i}" onclick="uploadItem(${i})">Upload</button>`;
|
|
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();
|
|
}
|
|
});
|
|
|