feat: build premium neon glassmorphic UI and interactive cropper with pixel auto-trim
This commit is contained in:
+763
@@ -0,0 +1,763 @@
|
||||
// Aether BG Renderer Processes
|
||||
|
||||
// --- GLOBAL VARIABLES & STATE ---
|
||||
let cropper = null;
|
||||
let currentImagePath = null;
|
||||
let originalFileName = '';
|
||||
let processedTempPath = null;
|
||||
let activeBgMode = 'transparent'; // 'transparent', 'solid', 'gradient'
|
||||
let activeSolidColor = '#ffffff';
|
||||
let activeGradientStops = [
|
||||
{ offset: 0, color: '#a78bfa' },
|
||||
{ offset: 1, color: '#06b6d4' }
|
||||
];
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
// Define gradient templates for canvas exporting
|
||||
const GRADIENTS = {
|
||||
'grad-1': [{ offset: 0, color: '#a78bfa' }, { offset: 1, color: '#06b6d4' }], // Violet Cyan
|
||||
'grad-2': [{ offset: 0, color: '#f43f5e' }, { offset: 1, color: '#f97316' }], // Sunset
|
||||
'grad-3': [{ offset: 0, color: '#10b981' }, { offset: 1, color: '#06b6d4' }], // Emerald
|
||||
'grad-4': [{ offset: 0, color: '#ec4899' }, { offset: 1, color: '#8b5cf6' }], // Cyberpunk
|
||||
'grad-5': [{ offset: 0, color: '#3b82f6' }, { offset: 1, color: '#8b5cf6' }], // Aether Blue
|
||||
'grad-6': [{ offset: 0, color: '#f59e0b' }, { offset: 1, color: '#ef4444' }] // Warm Ember
|
||||
};
|
||||
|
||||
// --- DOM ELEMENTS SELECTION ---
|
||||
const elements = {
|
||||
// Titlebar
|
||||
winMin: document.getElementById('win-min'),
|
||||
winMax: document.getElementById('win-max'),
|
||||
winClose: document.getElementById('win-close'),
|
||||
|
||||
// Setup Wizard
|
||||
setupOverlay: document.getElementById('setup-overlay'),
|
||||
btnStartSetup: document.getElementById('btn-start-setup'),
|
||||
setupProgressBar: document.getElementById('setup-progress-bar'),
|
||||
setupStatusText: document.getElementById('setup-status-text'),
|
||||
stepVenv: document.getElementById('step-venv'),
|
||||
stepPip: document.getElementById('step-pip'),
|
||||
stepDeps: document.getElementById('step-deps'),
|
||||
stepLibs: document.getElementById('step-libs'),
|
||||
|
||||
// Workspace Views
|
||||
dropzone: document.getElementById('dropzone'),
|
||||
btnBrowse: document.getElementById('btn-browse'),
|
||||
editorContainer: document.getElementById('editor-container'),
|
||||
canvasWrapper: document.getElementById('canvas-wrapper'),
|
||||
imageElement: document.getElementById('image-element'),
|
||||
canvasBgOverlay: document.getElementById('canvas-bg-overlay'),
|
||||
|
||||
// Workspace Toolbar Controls
|
||||
ctrlCropDrag: document.getElementById('ctrl-crop-drag'),
|
||||
ctrlCropBox: document.getElementById('ctrl-crop-box'),
|
||||
ctrlRotL: document.getElementById('ctrl-rot-l'),
|
||||
ctrlRotR: document.getElementById('ctrl-rot-r'),
|
||||
ctrlFlipH: document.getElementById('ctrl-flip-h'),
|
||||
ctrlFlipV: document.getElementById('ctrl-flip-v'),
|
||||
cropRatioSelect: document.getElementById('crop-ratio-select'),
|
||||
ctrlAutoTrim: document.getElementById('ctrl-auto-trim'),
|
||||
ctrlReset: document.getElementById('ctrl-reset'),
|
||||
|
||||
// Control Tower: Info Panel
|
||||
infoCardEmpty: document.getElementById('info-card-empty'),
|
||||
infoCardLoaded: document.getElementById('info-card-loaded'),
|
||||
infoFilename: document.getElementById('info-filename'),
|
||||
infoResolution: document.getElementById('info-resolution'),
|
||||
infoSize: document.getElementById('info-size'),
|
||||
|
||||
// Control Tower: AI Processor
|
||||
btnRemoveBg: document.getElementById('btn-remove-bg'),
|
||||
loaderBox: document.getElementById('loader-box'),
|
||||
loaderText: document.getElementById('loader-text'),
|
||||
loaderSubtext: document.getElementById('loader-subtext'),
|
||||
|
||||
// Control Tower: Background settings
|
||||
secCanvasBg: document.getElementById('sec-canvas-bg'),
|
||||
tabBgTrans: document.getElementById('tab-bg-trans'),
|
||||
tabBgSolid: document.getElementById('tab-bg-solid'),
|
||||
tabBgGrad: document.getElementById('tab-bg-grad'),
|
||||
subpanelSolid: document.getElementById('subpanel-solid'),
|
||||
subpanelGradient: document.getElementById('subpanel-gradient'),
|
||||
customColorPicker: document.getElementById('custom-color-picker'),
|
||||
btnCustomColorPicker: document.getElementById('btn-custom-color-picker'),
|
||||
|
||||
// Control Tower: Export Studio
|
||||
secExport: document.getElementById('sec-export'),
|
||||
exportFormat: document.getElementById('export-format'),
|
||||
rowQualitySlider: document.getElementById('row-quality-slider'),
|
||||
exportQuality: document.getElementById('export-quality'),
|
||||
exportQualityVal: document.getElementById('export-quality-val'),
|
||||
btnExport: document.getElementById('btn-export'),
|
||||
|
||||
// Toast Banner
|
||||
toastBanner: document.getElementById('toast-banner'),
|
||||
toastTitle: document.getElementById('toast-title'),
|
||||
toastMessage: document.getElementById('toast-message'),
|
||||
btnCloseToast: document.getElementById('btn-close-toast')
|
||||
};
|
||||
|
||||
// --- INITIALIZE & ENVIRONMENT CHECKS ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupTitlebarListeners();
|
||||
setupToastListeners();
|
||||
|
||||
// Check if Python virtual environment exists
|
||||
const { hasVenv } = await window.api.checkPythonSetup();
|
||||
if (!hasVenv) {
|
||||
elements.setupOverlay.classList.remove('hidden');
|
||||
} else {
|
||||
initWorkspace();
|
||||
}
|
||||
});
|
||||
|
||||
// Titlebar Controls
|
||||
function setupTitlebarListeners() {
|
||||
elements.winMin.addEventListener('click', () => window.api.minimizeWindow());
|
||||
elements.winMax.addEventListener('click', () => window.api.maximizeWindow());
|
||||
elements.winClose.addEventListener('click', () => window.api.closeWindow());
|
||||
}
|
||||
|
||||
// Workspace Initialization
|
||||
function initWorkspace() {
|
||||
setupDragAndDrop();
|
||||
setupToolbarListeners();
|
||||
setupControlTowerListeners();
|
||||
setupBgSwatches();
|
||||
|
||||
// Browse Button click
|
||||
elements.btnBrowse.addEventListener('click', selectLocalImage);
|
||||
elements.dropzone.addEventListener('click', (e) => {
|
||||
// Only open if clicking container directly and not buttons
|
||||
if (e.target === elements.dropzone || e.target.closest('.dropzone-content') && !e.target.closest('button')) {
|
||||
selectLocalImage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- PYTHON VENV INSTALLER ENGINE ---
|
||||
elements.btnStartSetup.addEventListener('click', async () => {
|
||||
elements.btnStartSetup.disabled = true;
|
||||
elements.setupProgressBar.parentElement.style.display = 'block';
|
||||
elements.setupProgressBar.style.width = '5%';
|
||||
|
||||
// Track active steps
|
||||
const setStepActive = (stepEl) => {
|
||||
document.querySelectorAll('.step-item').forEach(el => el.classList.remove('active'));
|
||||
stepEl.classList.add('active');
|
||||
};
|
||||
|
||||
const setStepDone = (stepEl) => {
|
||||
stepEl.classList.remove('active');
|
||||
stepEl.classList.add('done');
|
||||
};
|
||||
|
||||
const setStepFailed = (stepEl) => {
|
||||
stepEl.classList.remove('active');
|
||||
stepEl.classList.add('failed');
|
||||
};
|
||||
|
||||
setStepActive(elements.stepVenv);
|
||||
|
||||
// Listen for pipeline setup output logs
|
||||
window.api.onSetupProgress((progressText) => {
|
||||
elements.setupStatusText.innerText = progressText;
|
||||
|
||||
// Parse step state
|
||||
if (progressText.includes("Creating Python virtual environment")) {
|
||||
setStepActive(elements.stepVenv);
|
||||
elements.setupProgressBar.style.width = '15%';
|
||||
} else if (progressText.includes("Virtual environment successfully created") || progressText.includes("Virtual environment already exists")) {
|
||||
setStepDone(elements.stepVenv);
|
||||
setStepActive(elements.stepPip);
|
||||
elements.setupProgressBar.style.width = '30%';
|
||||
} else if (progressText.includes("Upgrading pip")) {
|
||||
setStepActive(elements.stepPip);
|
||||
elements.setupProgressBar.style.width = '45%';
|
||||
} else if (progressText.includes("Installing PyTorch")) {
|
||||
setStepDone(elements.stepPip);
|
||||
setStepActive(elements.stepDeps);
|
||||
elements.setupProgressBar.style.width = '60%';
|
||||
} else if (progressText.includes("Downloading torch") || progressText.includes("Installing collected packages")) {
|
||||
elements.setupProgressBar.style.width = '75%';
|
||||
} else if (progressText.includes("Setup completed successfully")) {
|
||||
setStepDone(elements.stepDeps);
|
||||
setStepDone(elements.stepLibs);
|
||||
elements.setupProgressBar.style.width = '100%';
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await window.api.runPythonSetup();
|
||||
if (result.code === 0) {
|
||||
elements.setupStatusText.innerText = "Configuration Complete! Enjoy background removal.";
|
||||
showToast("Setup Success", "Python environment is fully configured.", "success");
|
||||
|
||||
setTimeout(() => {
|
||||
elements.setupOverlay.classList.add('hidden');
|
||||
initWorkspace();
|
||||
}, 1500);
|
||||
} else {
|
||||
setStepFailed(elements.stepDeps);
|
||||
elements.setupStatusText.innerText = "Setup failed. Check system dependencies.";
|
||||
elements.btnStartSetup.disabled = false;
|
||||
showToast("Setup Error", "Failed to build Python environment.", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
elements.setupStatusText.innerText = `Error: ${err.message}`;
|
||||
elements.btnStartSetup.disabled = false;
|
||||
showToast("Setup Error", err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- IMAGE FILE INPUT ENGINE ---
|
||||
function setupDragAndDrop() {
|
||||
const dropzone = elements.dropzone;
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('dragover');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
if (files.length > 0) {
|
||||
handleImageSelection(files[0].path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function selectLocalImage() {
|
||||
const filePath = await window.api.selectImage();
|
||||
if (filePath) {
|
||||
handleImageSelection(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageSelection(filePath) {
|
||||
currentImagePath = filePath;
|
||||
originalFileName = filePath.split(/[\\/]/).pop();
|
||||
|
||||
// Reset previous states
|
||||
processedTempPath = null;
|
||||
activeBgMode = 'transparent';
|
||||
scaleX = 1;
|
||||
scaleY = 1;
|
||||
resetBgSettings();
|
||||
|
||||
// Show image in preview element
|
||||
elements.imageElement.src = `file://${filePath}`;
|
||||
|
||||
// Set file metadata in control tower
|
||||
const stats = getFileStats(filePath);
|
||||
elements.infoFilename.innerText = originalFileName;
|
||||
elements.infoSize.innerText = stats.sizeFormatted;
|
||||
|
||||
// Load dimensions
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
elements.infoResolution.innerText = `${this.naturalWidth} x ${this.naturalHeight} px`;
|
||||
|
||||
// Switch views
|
||||
elements.dropzone.classList.add('hidden');
|
||||
elements.editorContainer.classList.remove('hidden');
|
||||
elements.infoCardEmpty.classList.add('hidden');
|
||||
elements.infoCardLoaded.classList.remove('hidden');
|
||||
|
||||
// Enable AI button
|
||||
elements.btnRemoveBg.disabled = false;
|
||||
|
||||
// Initialize CropperJS on image
|
||||
initCropper();
|
||||
};
|
||||
img.src = `file://${filePath}`;
|
||||
}
|
||||
|
||||
function getFileStats(path) {
|
||||
// Simple synchronous-like wrapper or mockup stats.
|
||||
// Note: Electron file handles path directly.
|
||||
return {
|
||||
sizeFormatted: "Calculating..." // Updated as soon as loaded
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: Formatter
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// --- CROPPERJS CONTROLLER HUB ---
|
||||
function initCropper() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
}
|
||||
|
||||
// Load Cropper configuration
|
||||
cropper = new Cropper(elements.imageElement, {
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCrop: false,
|
||||
responsive: true,
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
});
|
||||
|
||||
// Wire toolbar click listeners
|
||||
elements.ctrlCropDrag.classList.add('active');
|
||||
elements.ctrlCropBox.classList.remove('active');
|
||||
elements.ctrlAutoTrim.disabled = true; // Disabled until background is removed
|
||||
}
|
||||
|
||||
function setupToolbarListeners() {
|
||||
elements.ctrlCropDrag.addEventListener('click', () => {
|
||||
cropper.setDragMode('move');
|
||||
elements.ctrlCropDrag.classList.add('active');
|
||||
elements.ctrlCropBox.classList.remove('active');
|
||||
});
|
||||
|
||||
elements.ctrlCropBox.addEventListener('click', () => {
|
||||
cropper.setDragMode('crop');
|
||||
elements.ctrlCropDrag.classList.remove('active');
|
||||
elements.ctrlCropBox.classList.add('active');
|
||||
});
|
||||
|
||||
elements.ctrlRotL.addEventListener('click', () => cropper.rotate(-90));
|
||||
elements.ctrlRotR.addEventListener('click', () => cropper.rotate(90));
|
||||
|
||||
elements.ctrlFlipH.addEventListener('click', () => {
|
||||
scaleX = scaleX === 1 ? -1 : 1;
|
||||
cropper.scale(scaleX, scaleY);
|
||||
});
|
||||
|
||||
elements.ctrlFlipV.addEventListener('click', () => {
|
||||
scaleY = scaleY === 1 ? -1 : 1;
|
||||
cropper.scale(scaleX, scaleY);
|
||||
});
|
||||
|
||||
elements.cropRatioSelect.addEventListener('change', (e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
cropper.setAspectRatio(value);
|
||||
});
|
||||
|
||||
elements.ctrlReset.addEventListener('click', () => {
|
||||
cropper.reset();
|
||||
scaleX = 1;
|
||||
scaleY = 1;
|
||||
cropper.scale(1, 1);
|
||||
elements.cropRatioSelect.value = "NaN";
|
||||
cropper.setAspectRatio(NaN);
|
||||
showToast("Reset Action", "Workspace state cleared.", "info");
|
||||
});
|
||||
|
||||
elements.ctrlAutoTrim.addEventListener('click', autoTrimTransparent);
|
||||
}
|
||||
|
||||
// --- NATIVE AUTOTRIM (PIXEL ITERATION BOUNDS) ---
|
||||
function autoTrimTransparent() {
|
||||
if (!cropper || !processedTempPath) return;
|
||||
|
||||
// We request canvas size matching the full uncropped image dimension
|
||||
const canvas = cropper.getCroppedCanvas({
|
||||
width: cropper.getImageData().naturalWidth,
|
||||
height: cropper.getImageData().naturalHeight
|
||||
});
|
||||
|
||||
if (!canvas) {
|
||||
showToast("Trim Warning", "Could not analyze transparent boundaries.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imgData.data;
|
||||
|
||||
let minX = canvas.width;
|
||||
let maxX = 0;
|
||||
let minY = canvas.height;
|
||||
let maxY = 0;
|
||||
let foundAlpha = false;
|
||||
|
||||
// Loop through pixels in grid to find bounding box of non-transparent values
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
const alphaIndex = (y * canvas.width + x) * 4 + 3;
|
||||
if (data[alphaIndex] > 8) { // Alpha transparency cut-off
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
foundAlpha = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAlpha) {
|
||||
// Add small padding to avoid clipping exactly
|
||||
const pad = 10;
|
||||
minX = Math.max(0, minX - pad);
|
||||
minY = Math.max(0, minY - pad);
|
||||
maxX = Math.min(canvas.width, maxX + pad);
|
||||
maxY = Math.min(canvas.height, maxY + pad);
|
||||
|
||||
// Convert pixel coordinates to Cropper canvas boundaries
|
||||
const canvasData = cropper.getCanvasData();
|
||||
const scale = canvasData.width / cropper.getImageData().naturalWidth;
|
||||
|
||||
// Set crop boundaries
|
||||
cropper.setCropBoxData({
|
||||
left: canvasData.left + minX * scale,
|
||||
top: canvasData.top + minY * scale,
|
||||
width: (maxX - minX) * scale,
|
||||
height: (maxY - minY) * scale
|
||||
});
|
||||
|
||||
// Switch to crop selection mode visual indicator
|
||||
cropper.setDragMode('crop');
|
||||
elements.ctrlCropDrag.classList.remove('active');
|
||||
elements.ctrlCropBox.classList.add('active');
|
||||
|
||||
showToast("Auto-Trim Complete", "Crop boundaries adjusted securely around subject.", "success");
|
||||
} else {
|
||||
showToast("Trim Info", "No transparent border boundaries identified.", "info");
|
||||
}
|
||||
}
|
||||
|
||||
// --- CONTROL TOWER CONTROLLER ---
|
||||
function setupControlTowerListeners() {
|
||||
// Remove Background click
|
||||
elements.btnRemoveBg.addEventListener('click', executeBackgroundRemoval);
|
||||
|
||||
// Background Settings Tabs
|
||||
elements.tabBgTrans.addEventListener('click', () => switchBgMode('transparent'));
|
||||
elements.tabBgSolid.addEventListener('click', () => switchBgMode('solid'));
|
||||
elements.tabBgGrad.addEventListener('click', () => switchBgMode('gradient'));
|
||||
|
||||
// Export Quality Slider
|
||||
elements.exportFormat.addEventListener('change', (e) => {
|
||||
if (e.target.value === 'png') {
|
||||
elements.rowQualitySlider.classList.add('hidden');
|
||||
} else {
|
||||
elements.rowQualitySlider.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
elements.exportQuality.addEventListener('input', (e) => {
|
||||
elements.exportQualityVal.innerText = `${e.target.value}%`;
|
||||
});
|
||||
|
||||
// Export MASTERPIECE Click
|
||||
elements.btnExport.addEventListener('click', executeExport);
|
||||
}
|
||||
|
||||
// --- BIREFNET EXECUTION PIPELINE ---
|
||||
async function executeBackgroundRemoval() {
|
||||
if (!currentImagePath) return;
|
||||
|
||||
elements.btnRemoveBg.classList.add('hidden');
|
||||
elements.loaderBox.classList.remove('hidden');
|
||||
elements.loaderText.innerText = "Initializing AI model...";
|
||||
elements.loaderSubtext.innerText = "Loading neural weights into memory...";
|
||||
|
||||
// Get unique temporary output file path
|
||||
const tempOut = await window.api.getTempPath('transparent_result.png');
|
||||
|
||||
// Listen to execution progress
|
||||
window.api.onProcessProgress((progress) => {
|
||||
if (progress.includes("[STATUS]")) {
|
||||
const statusText = progress.replace("[STATUS] ", "");
|
||||
elements.loaderText.innerText = statusText;
|
||||
|
||||
if (statusText.includes("Hugging Face")) {
|
||||
elements.loaderSubtext.innerText = "This downloads ~200MB of weights on first run.";
|
||||
} else if (statusText.includes("inference")) {
|
||||
elements.loaderSubtext.innerText = "Extracting details and soft matting alpha...";
|
||||
} else if (statusText.includes("Saving")) {
|
||||
elements.loaderSubtext.innerText = "Assembling high-res RGBA container...";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await window.api.removeBackground(currentImagePath, tempOut);
|
||||
|
||||
if (result.success) {
|
||||
processedTempPath = result.outputPath;
|
||||
showToast("AI Success", "Image background removed successfully!", "success");
|
||||
|
||||
// Update workspace preview image source to output transparent PNG
|
||||
// Add timestamp to bypass browser cache refreshing
|
||||
const freshSrc = `file://${processedTempPath}?t=${Date.now()}`;
|
||||
|
||||
// Re-initialize CropperJS on the new image src
|
||||
elements.imageElement.src = freshSrc;
|
||||
|
||||
// Wait for image source update to build the cropper safely
|
||||
setTimeout(() => {
|
||||
initCropper();
|
||||
|
||||
// Unlock background customization and export
|
||||
elements.secCanvasBg.classList.remove('disabled-group');
|
||||
elements.secExport.classList.remove('disabled-group');
|
||||
elements.ctrlAutoTrim.disabled = false; // Unlock trim feature!
|
||||
}, 300);
|
||||
|
||||
} else {
|
||||
showToast("Inference Error", result.error || "Execution terminated unexpectedly.", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("AI Fatal Error", err.message, "error");
|
||||
} finally {
|
||||
elements.loaderBox.classList.add('hidden');
|
||||
elements.btnRemoveBg.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// --- CANVAS CUSTOM BACKGROUND SETTINGS ---
|
||||
function resetBgSettings() {
|
||||
elements.secCanvasBg.classList.add('disabled-group');
|
||||
elements.secExport.classList.add('disabled-group');
|
||||
switchBgMode('transparent');
|
||||
}
|
||||
|
||||
function switchBgMode(mode) {
|
||||
activeBgMode = mode;
|
||||
|
||||
// Adjust active tab css
|
||||
elements.tabBgTrans.classList.remove('active');
|
||||
elements.tabBgSolid.classList.remove('active');
|
||||
elements.tabBgGrad.classList.remove('active');
|
||||
|
||||
elements.subpanelSolid.classList.add('hidden');
|
||||
elements.subpanelGradient.classList.add('hidden');
|
||||
|
||||
if (mode === 'transparent') {
|
||||
elements.tabBgTrans.classList.add('active');
|
||||
elements.canvasBgOverlay.style.opacity = '0';
|
||||
} else if (mode === 'solid') {
|
||||
elements.tabBgSolid.classList.add('active');
|
||||
elements.subpanelSolid.classList.remove('hidden');
|
||||
elements.canvasBgOverlay.style.background = activeSolidColor;
|
||||
elements.canvasBgOverlay.style.opacity = '1';
|
||||
} else if (mode === 'gradient') {
|
||||
elements.tabBgGrad.classList.add('active');
|
||||
elements.subpanelGradient.classList.remove('hidden');
|
||||
|
||||
// Draw current active gradient stops onto the workspace preview
|
||||
const cssGrad = buildCssGradientString(activeGradientStops);
|
||||
elements.canvasBgOverlay.style.background = cssGrad;
|
||||
elements.canvasBgOverlay.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function buildCssGradientString(stops) {
|
||||
return `linear-gradient(135deg, ${stops[0].color}, ${stops[1].color})`;
|
||||
}
|
||||
|
||||
function setupBgSwatches() {
|
||||
// Solid Swatches
|
||||
const solidSwatches = document.querySelectorAll('.swatch:not(.custom)');
|
||||
solidSwatches.forEach(swatch => {
|
||||
swatch.addEventListener('click', (e) => {
|
||||
solidSwatches.forEach(s => s.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
activeSolidColor = e.target.getAttribute('data-color');
|
||||
elements.canvasBgOverlay.style.background = activeSolidColor;
|
||||
});
|
||||
});
|
||||
|
||||
// Custom Color Picker
|
||||
elements.customColorPicker.addEventListener('input', (e) => {
|
||||
activeSolidColor = e.target.value;
|
||||
elements.canvasBgOverlay.style.background = activeSolidColor;
|
||||
|
||||
// Highlight custom swatch borders
|
||||
elements.btnCustomColorPicker.classList.add('active');
|
||||
solidSwatches.forEach(s => s.classList.remove('active'));
|
||||
});
|
||||
|
||||
// Gradient Swatches
|
||||
const gradSwatches = document.querySelectorAll('.grad-swatch');
|
||||
gradSwatches.forEach(swatch => {
|
||||
swatch.addEventListener('click', (e) => {
|
||||
gradSwatches.forEach(s => s.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
const gradKey = e.target.className.split(' ').find(cls => cls.startsWith('grad-'));
|
||||
if (GRADIENTS[gradKey]) {
|
||||
activeGradientStops = GRADIENTS[gradKey];
|
||||
elements.canvasBgOverlay.style.background = e.target.getAttribute('data-gradient');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- CANVAS EXPORT AND MERGING ENGINE ---
|
||||
async function executeExport() {
|
||||
if (!cropper) return;
|
||||
|
||||
elements.btnExport.disabled = true;
|
||||
elements.btnExport.innerHTML = `
|
||||
<svg class="loader-ring" viewBox="0 0 50 50" style="width:16px;height:16px;margin:0;"><circle cx="25" cy="25" r="20" fill="none" stroke-width="4" stroke="#fff"></circle></svg>
|
||||
<span>Exporting masterpiece...</span>
|
||||
`;
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Get the cropped canvas at original resolution
|
||||
const croppedCanvas = cropper.getCroppedCanvas();
|
||||
|
||||
if (!croppedCanvas) {
|
||||
throw new Error("Failed to extract active cropped canvas workspace.");
|
||||
}
|
||||
|
||||
let finalCanvas = croppedCanvas;
|
||||
|
||||
// If solid or gradient background mode is selected, we burn it behind the subject
|
||||
if (activeBgMode !== 'transparent') {
|
||||
finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = croppedCanvas.width;
|
||||
finalCanvas.height = croppedCanvas.height;
|
||||
const ctx = finalCanvas.getContext('2d');
|
||||
|
||||
if (activeBgMode === 'solid') {
|
||||
ctx.fillStyle = activeSolidColor;
|
||||
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
||||
} else if (activeBgMode === 'gradient') {
|
||||
// Render the 135deg gradient vector on final output canvas
|
||||
const grad = ctx.createLinearGradient(0, 0, finalCanvas.width, finalCanvas.height);
|
||||
activeGradientStops.forEach(stop => {
|
||||
grad.addColorStop(stop.offset, stop.color);
|
||||
});
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
||||
}
|
||||
|
||||
// Overlay transparent subject on top of custom background
|
||||
ctx.drawImage(croppedCanvas, 0, 0);
|
||||
}
|
||||
|
||||
// Read Export Format settings
|
||||
const format = elements.exportFormat.value;
|
||||
let mimeType = 'image/png';
|
||||
let extension = 'png';
|
||||
|
||||
if (format === 'jpeg') {
|
||||
mimeType = 'image/jpeg';
|
||||
extension = 'jpg';
|
||||
} else if (format === 'webp') {
|
||||
mimeType = 'image/webp';
|
||||
extension = 'webp';
|
||||
}
|
||||
|
||||
const quality = parseFloat(elements.exportQuality.value) / 100;
|
||||
const base64Data = finalCanvas.toDataURL(mimeType, quality);
|
||||
|
||||
// Create default filename suggestion
|
||||
const nameWithoutExt = originalFileName.substring(0, originalFileName.lastIndexOf('.')) || originalFileName;
|
||||
const exportDefaultName = `${nameWithoutExt}_no_bg.${extension}`;
|
||||
|
||||
// Open Save File dialog via context bridge
|
||||
const result = await window.api.exportImage(base64Data, exportDefaultName);
|
||||
|
||||
if (result.success) {
|
||||
showToastWithAction(
|
||||
"Export Successful",
|
||||
`Saved file correctly: ${result.filePath.split(/[\\/]/).pop()}`,
|
||||
"success",
|
||||
result.filePath
|
||||
);
|
||||
} else if (!result.canceled) {
|
||||
showToast("Export Failed", result.error || "Save operation failed.", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Export Error", err.message, "error");
|
||||
} finally {
|
||||
// Re-enable button
|
||||
elements.btnExport.disabled = false;
|
||||
elements.btnExport.innerHTML = `
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
<span>Export Masterpiece</span>
|
||||
`;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// --- GLOBAL TOAST SYSTEM IMPLEMENTATION ---
|
||||
let toastTimeout = null;
|
||||
|
||||
function setupToastListeners() {
|
||||
elements.btnCloseToast.addEventListener('click', hideToast);
|
||||
}
|
||||
|
||||
function showToast(title, message, type = 'info') {
|
||||
hideToast();
|
||||
|
||||
elements.toastTitle.innerText = title;
|
||||
elements.toastMessage.innerText = message;
|
||||
|
||||
// Styling
|
||||
elements.toastBanner.className = 'toast-notification glass-panel-inner';
|
||||
if (type === 'success') elements.toastBanner.classList.add('success');
|
||||
if (type === 'error') elements.toastBanner.classList.add('error');
|
||||
|
||||
elements.toastBanner.classList.remove('hidden');
|
||||
|
||||
// Set automatic dismissal
|
||||
toastTimeout = setTimeout(hideToast, 5000);
|
||||
}
|
||||
|
||||
function showToastWithAction(title, message, type = 'success', filePath) {
|
||||
hideToast();
|
||||
|
||||
elements.toastTitle.innerText = title;
|
||||
elements.toastMessage.innerHTML = `${message} <br><button id="btn-toast-explore" class="btn btn-secondary btn-sm" style="margin-top:6px;width:100%;">Show in File Explorer</button>`;
|
||||
|
||||
elements.toastBanner.className = 'toast-notification glass-panel-inner';
|
||||
if (type === 'success') elements.toastBanner.classList.add('success');
|
||||
if (type === 'error') elements.toastBanner.classList.add('error');
|
||||
|
||||
elements.toastBanner.classList.remove('hidden');
|
||||
|
||||
// Bind click logic to "Show in File Explorer" button
|
||||
setTimeout(() => {
|
||||
const btnExplore = document.getElementById('btn-toast-explore');
|
||||
if (btnExplore) {
|
||||
btnExplore.addEventListener('click', () => {
|
||||
window.api.showInExplorer(filePath);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Keep it active longer since it contains interaction
|
||||
toastTimeout = setTimeout(hideToast, 8000);
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (toastTimeout) {
|
||||
clearTimeout(toastTimeout);
|
||||
toastTimeout = null;
|
||||
}
|
||||
elements.toastBanner.classList.add('hidden');
|
||||
}
|
||||
Reference in New Issue
Block a user