diff --git a/backend/requirements.txt b/backend/requirements.txt index 386f674..8553437 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,8 +14,9 @@ pydantic-settings==2.1.0 email-validator==2.1.0 opencv-python-headless==4.9.0.80 scikit-image==0.22.0 +# rembg for background removal - use onnxruntime 1.15.1 to avoid executable stack issue +onnxruntime==1.15.1 rembg==2.0.50 -onnxruntime==1.16.3 # SAM (Segment Anything) for smart object selection - runs locally, no API needed torch==2.1.2 torchvision==0.16.2 diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index 5060016..2274e2b 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -518,6 +518,127 @@ IMPORTANT: any new icon should also must be added on /service-worker.js + its ve bottom:0; width: 20px; } +/* Layer context menu */ +.layer_context_menu{ + display: none; + position: fixed; + z-index: 1000; + background: var(--section-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + min-width: 150px; +} +.layer_context_menu ul{ + list-style: none; + margin: 0; + padding: 5px 0; +} +.layer_context_menu li{ + padding: 6px 15px; + cursor: pointer; + color: var(--text-color); + font-size: 13px; +} +.layer_context_menu li:hover{ + background: var(--background-color-active); + color: var(--text-color-active); +} +.layer_context_menu li.separator{ + height: 1px; + background: var(--border-color); + margin: 5px 10px; + padding: 0; + cursor: default; +} +.layer_context_menu li.separator:hover{ + background: var(--border-color); +} +.layer_scale{ + display:inline-block; + padding:1px 8px; + margin-right: 5px; + border:1px solid #444; + border-color: var(--border-color); + color: var(--text-color); + cursor:pointer; +} +/* My Library browser */ +.library-browser{ + max-height: 400px; + overflow-y: auto; + padding: 10px; +} +.library-category{ + margin-bottom: 20px; +} +.library-category h3{ + margin: 0 0 10px 0; + padding-bottom: 5px; + border-bottom: 1px solid var(--border-color); + color: var(--text-color); + font-size: 14px; +} +.library-items{ + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.library-item{ + width: 120px; + padding: 8px; + background: var(--area-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: 0.2s; +} +.library-item:hover{ + border-color: var(--background-color-active); + transform: scale(1.02); +} +.library-item img{ + width: 100%; + height: 80px; + object-fit: contain; + background: repeating-conic-gradient(#808080 0% 25%, #666 0% 50%) 50% / 10px 10px; + border-radius: 2px; +} +.library-item-name{ + margin-top: 5px; + font-size: 11px; + color: var(--text-color); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.library-item-actions{ + display: flex; + gap: 5px; + margin-top: 5px; +} +.library-item-actions button{ + flex: 1; + padding: 3px 5px; + font-size: 10px; + cursor: pointer; + border: 1px solid var(--border-color); + border-radius: 3px; + background: var(--section-background-color); + color: var(--text-color); +} +.library-item-actions .insert-btn{ + background: var(--background-color-active); + color: var(--text-color-active); +} +.library-item-actions .delete-btn:hover{ + background: #dc3545; + color: #fff; +} +.library-dialog .dialog_content{ + min-width: 500px; +} .sidebar_right .label{ display: inline-block; } diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js index eb76ec6..e99b763 100644 --- a/frontend/src/js/config-menu.js +++ b/frontend/src/js/config-menu.js @@ -86,6 +86,44 @@ const menuDefinition = [ name: 'Quick Load', shortcut: 'F10', target: 'file/quickload.quickload' + }, + { + divider: true + }, + { + name: 'My Library', + children: [ + { + name: 'Browse Library', + ellipsis: true, + target: 'file/my_library.browse_library' + }, + { + divider: true + }, + { + name: 'Save Layer to Library', + ellipsis: true, + target: 'file/my_library.save_to_library' + }, + { + name: 'Save Selection to Library', + ellipsis: true, + target: 'file/my_library.save_selection_to_library' + }, + { + divider: true + }, + { + name: 'Export Library (Backup)', + target: 'file/my_library.export_library' + }, + { + name: 'Import Library', + ellipsis: true, + target: 'file/my_library.import_library' + } + ] } ] }, @@ -291,6 +329,29 @@ const menuDefinition = [ name: 'Remove Background (AI)', ellipsis: true, target: 'image/remove_background.remove_background' + }, + { + divider: true + }, + { + name: 'Selection Effects', + children: [ + { + name: 'Invert Selection', + ellipsis: true, + target: 'image/selection_effects.invert_selection' + }, + { + name: 'Adjust Selection', + ellipsis: true, + target: 'image/selection_effects.adjust_selection' + }, + { + name: 'Greyscale Selection', + ellipsis: true, + target: 'image/selection_effects.greyscale_selection' + } + ] } ] }, @@ -326,6 +387,11 @@ const menuDefinition = [ name: 'Convert to Raster', target: 'layer/raster.raster' }, + { + name: 'Scale Layer', + ellipsis: true, + target: 'layer/scale.scale' + }, { divider: true }, @@ -480,6 +546,11 @@ const menuDefinition = [ ellipsis: true, target: 'effects/black_and_white.black_and_white' }, + { + name: 'Greyscale', + ellipsis: true, + target: 'effects/greyscale.greyscale' + }, { name: 'Borders', ellipsis: true, diff --git a/frontend/src/js/core/gui/gui-layers.js b/frontend/src/js/core/gui/gui-layers.js index 87fb052..b00cdf2 100644 --- a/frontend/src/js/core/gui/gui-layers.js +++ b/frontend/src/js/core/gui/gui-layers.js @@ -11,17 +11,22 @@ import Layer_rename_class from './../../modules/layer/rename.js'; import Effects_browser_class from './../../modules/effects/browser.js'; import Layer_duplicate_class from './../../modules/layer/duplicate.js'; import Layer_raster_class from './../../modules/layer/raster.js'; +import Layer_scale_class from './../../modules/layer/scale.js'; +import Layer_merge_class from './../../modules/layer/merge.js'; +import Layer_flatten_class from './../../modules/layer/flatten.js'; import Tools_translate_class from './../../modules/tools/translate.js'; var template = ` +
+ `; /** @@ -36,7 +41,11 @@ class GUI_layers_class { this.Effects_browser = new Effects_browser_class(); this.Layer_duplicate = new Layer_duplicate_class(); this.Layer_raster = new Layer_raster_class(); + this.Layer_scale = new Layer_scale_class(); + this.Layer_merge = new Layer_merge_class(); + this.Layer_flatten = new Layer_flatten_class(); this.Tools_translate = new Tools_translate_class(); + this.contextMenuLayerId = null; } render_main_layers() { @@ -67,6 +76,10 @@ class GUI_layers_class { //raster _this.Layer_raster.raster(); } + else if (target.id == 'layer_scale') { + //scale + _this.Layer_scale.scale(); + } else if (target.id == 'layer_up') { //move layer up app.State.do_action( @@ -127,6 +140,134 @@ class GUI_layers_class { } }); + // Right-click context menu for layers + document.getElementById('layers_base').addEventListener('contextmenu', function (event) { + var target = event.target; + + // Check if right-clicked on a layer item + if (target.id == 'layer_name' || target.closest('.item')) { + event.preventDefault(); + + var layerId = target.dataset.id || target.closest('.item').querySelector('[data-id]').dataset.id; + _this.showContextMenu(event.clientX, event.clientY, layerId); + } + }); + + // Hide context menu when clicking elsewhere + document.addEventListener('click', function (event) { + _this.hideContextMenu(); + }); + + } + + /** + * Show context menu for layer + */ + showContextMenu(x, y, layerId) { + var _this = this; + this.contextMenuLayerId = layerId; + + // Select the layer first + if (layerId != config.layer.id) { + app.State.do_action( + new app.Actions.Select_layer_action(layerId) + ); + } + + var menuItems = [ + { label: 'Rename', action: 'rename' }, + { label: 'Duplicate', action: 'duplicate' }, + { label: 'Delete', action: 'delete' }, + { label: '---' }, + { label: 'Move Up', action: 'move_up' }, + { label: 'Move Down', action: 'move_down' }, + { label: '---' }, + { label: 'Scale Layer...', action: 'scale' }, + { label: 'Convert to Raster', action: 'raster' }, + { label: '---' }, + { label: 'Merge Down', action: 'merge' }, + { label: 'Flatten All', action: 'flatten' }, + ]; + + var menu = document.getElementById('layer_context_menu'); + var html = ''; + menu.innerHTML = html; + menu.style.display = 'block'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + // Add click handlers to menu items + menu.querySelectorAll('li[data-action]').forEach(function(item) { + item.addEventListener('click', function(e) { + e.stopPropagation(); + _this.handleContextMenuAction(this.dataset.action); + _this.hideContextMenu(); + }); + }); + } + + /** + * Hide context menu + */ + hideContextMenu() { + var menu = document.getElementById('layer_context_menu'); + if (menu) { + menu.style.display = 'none'; + } + } + + /** + * Handle context menu action + */ + handleContextMenuAction(action) { + var layerId = this.contextMenuLayerId; + + switch (action) { + case 'rename': + this.Layer_rename.rename(layerId); + break; + case 'duplicate': + this.Layer_duplicate.duplicate(); + break; + case 'delete': + app.State.do_action( + new app.Actions.Delete_layer_action(layerId) + ); + break; + case 'move_up': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, 1) + ); + break; + case 'move_down': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, -1) + ); + break; + case 'scale': + this.Layer_scale.scale(); + break; + case 'raster': + this.Layer_raster.raster(); + break; + case 'merge': + this.Layer_merge.merge(); + break; + case 'flatten': + this.Layer_flatten.flatten(); + break; + } } /** diff --git a/frontend/src/js/modules/effects/greyscale.js b/frontend/src/js/modules/effects/greyscale.js new file mode 100644 index 0000000..56ac996 --- /dev/null +++ b/frontend/src/js/modules/effects/greyscale.js @@ -0,0 +1,157 @@ +/** + * Greyscale Effect - Convert layer to greyscale (desaturate) + * Useful for CNC carving, depth maps, etc. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Effects_greyscale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + greyscale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster first.'); + return; + } + + var settings = { + title: 'Convert to Greyscale', + preview: true, + effects: true, + params: [ + { + name: "method", + title: "Method:", + values: ["Luminosity (Rec. 709)", "Average", "Lightness", "Red Channel", "Green Channel", "Blue Channel"], + value: "Luminosity (Rec. 709)" + }, + {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]}, + {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]}, + {name: "invert", title: "Invert:", value: false}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_greyscale(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + apply_greyscale(imageData, params) { + var data = imageData.data; + var method = params.method; + var contrast = (params.contrast || 0) / 100; + var brightness = (params.brightness || 0) * 2.55; // Convert to 0-255 range + var invert = params.invert || false; + + // Contrast factor + var factor = (1 + contrast); + + for (var i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) continue; // Skip transparent pixels + + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + var grey; + + // Calculate greyscale value based on method + switch (method) { + case "Luminosity (Rec. 709)": + // Standard HDTV (Rec. 709) - most accurate perceptual + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + break; + case "Average": + grey = (r + g + b) / 3; + break; + case "Lightness": + // HSL lightness + grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + break; + case "Red Channel": + grey = r; + break; + case "Green Channel": + grey = g; + break; + case "Blue Channel": + grey = b; + break; + default: + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + // Apply brightness + grey += brightness; + + // Apply contrast (around middle grey) + grey = ((grey - 128) * factor) + 128; + + // Invert if requested + if (invert) { + grey = 255 - grey; + } + + // Clamp to valid range + grey = Math.max(0, Math.min(255, Math.round(grey))); + + data[i] = grey; + data[i + 1] = grey; + data[i + 2] = grey; + } + + return imageData; + } + + demo(canvas_id, canvas_thumb) { + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + method: "Luminosity (Rec. 709)", + contrast: 0, + brightness: 0, + invert: false + }; + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + } +} + +export default Effects_greyscale_class; diff --git a/frontend/src/js/modules/file/my_library.js b/frontend/src/js/modules/file/my_library.js new file mode 100644 index 0000000..4d60869 --- /dev/null +++ b/frontend/src/js/modules/file/my_library.js @@ -0,0 +1,504 @@ +/** + * My Library - Save and reuse your own assets (clip art, templates, etc.) + * Assets are stored in browser's IndexedDB for persistence + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +// IndexedDB setup +const DB_NAME = 'miniPaintLibrary'; +const DB_VERSION = 1; +const STORE_NAME = 'assets'; + +class File_my_library_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.db = null; + + this.initDB(); + } + + /** + * Initialize IndexedDB + */ + initDB() { + var _this = this; + + var request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = function(event) { + console.error('IndexedDB error:', event); + }; + + request.onsuccess = function(event) { + _this.db = event.target.result; + }; + + request.onupgradeneeded = function(event) { + var db = event.target.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + store.createIndex('name', 'name', { unique: false }); + store.createIndex('category', 'category', { unique: false }); + store.createIndex('created', 'created', { unique: false }); + } + }; + } + + /** + * Save current layer as a library asset + */ + save_to_library() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer to save'); + return; + } + + var settings = { + title: 'Save to My Library', + params: [ + { + name: "name", + title: "Asset Name:", + value: config.layer.name || "My Asset" + }, + { + name: "category", + title: "Category:", + value: "General", + values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"] + }, + { + name: "description", + title: "Description:", + value: "" + } + ], + on_finish: function (params) { + _this.do_save_to_library(params); + }, + }; + this.POP.show(settings); + } + + /** + * Save current selection to library (uses mask if available) + */ + save_selection_to_library() { + var _this = this; + + if (!window.smartSelectMask || !window.smartSelectMask.canvas) { + alertify.error('No selection. Use a selection tool first.'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Save Selection to Library', + params: [ + { + name: "name", + title: "Asset Name:", + value: "Selection Asset" + }, + { + name: "category", + title: "Category:", + value: "General", + values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"] + }, + { + name: "description", + title: "Description:", + value: "" + } + ], + on_finish: function (params) { + _this.do_save_selection_to_library(params); + }, + }; + this.POP.show(settings); + } + + do_save_to_library(params) { + var _this = this; + + // Get canvas from current layer + var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, true, false); + + // Create thumbnail (max 150px) + var thumbCanvas = document.createElement('canvas'); + var maxSize = 150; + var scale = Math.min(maxSize / canvas.width, maxSize / canvas.height); + thumbCanvas.width = Math.round(canvas.width * scale); + thumbCanvas.height = Math.round(canvas.height * scale); + var thumbCtx = thumbCanvas.getContext('2d'); + thumbCtx.drawImage(canvas, 0, 0, thumbCanvas.width, thumbCanvas.height); + + var asset = { + name: params.name, + category: params.category, + description: params.description, + width: canvas.width, + height: canvas.height, + data: canvas.toDataURL('image/png'), + thumbnail: thumbCanvas.toDataURL('image/png'), + created: new Date().toISOString() + }; + + this.saveAsset(asset, function() { + alertify.success('Saved "' + params.name + '" to library!'); + }); + } + + do_save_selection_to_library(params) { + var _this = this; + var layer = config.layer; + var maskCanvas = window.smartSelectMask.canvas; + + // Create canvas with just the selected pixels + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(maskCanvas, 0, 0); + + // Find bounds of selection + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = imageData.data; + var minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; + + for (var y = 0; y < canvas.height; y++) { + for (var x = 0; x < canvas.width; x++) { + var i = (y * canvas.width + x) * 4; + if (data[i + 3] > 0) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + // Crop to selection bounds + var cropWidth = maxX - minX + 1; + var cropHeight = maxY - minY + 1; + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + croppedCtx.drawImage(canvas, minX, minY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); + + // Create thumbnail + var thumbCanvas = document.createElement('canvas'); + var maxSize = 150; + var scale = Math.min(maxSize / cropWidth, maxSize / cropHeight); + thumbCanvas.width = Math.round(cropWidth * scale); + thumbCanvas.height = Math.round(cropHeight * scale); + var thumbCtx = thumbCanvas.getContext('2d'); + thumbCtx.drawImage(croppedCanvas, 0, 0, thumbCanvas.width, thumbCanvas.height); + + var asset = { + name: params.name, + category: params.category, + description: params.description, + width: cropWidth, + height: cropHeight, + data: croppedCanvas.toDataURL('image/png'), + thumbnail: thumbCanvas.toDataURL('image/png'), + created: new Date().toISOString() + }; + + this.saveAsset(asset, function() { + alertify.success('Saved selection "' + params.name + '" to library!'); + }); + } + + saveAsset(asset, callback) { + if (!this.db) { + alertify.error('Database not ready, please try again'); + return; + } + + var transaction = this.db.transaction([STORE_NAME], 'readwrite'); + var store = transaction.objectStore(STORE_NAME); + var request = store.add(asset); + + request.onsuccess = function() { + if (callback) callback(); + }; + + request.onerror = function(event) { + alertify.error('Error saving asset'); + console.error('Save error:', event); + }; + } + + /** + * Browse and insert assets from library + */ + browse_library() { + var _this = this; + + this.getAllAssets(function(assets) { + _this.showLibraryBrowser(assets); + }); + } + + getAllAssets(callback) { + if (!this.db) { + alertify.error('Database not ready'); + callback([]); + return; + } + + var transaction = this.db.transaction([STORE_NAME], 'readonly'); + var store = transaction.objectStore(STORE_NAME); + var request = store.getAll(); + + request.onsuccess = function(event) { + callback(event.target.result || []); + }; + + request.onerror = function() { + callback([]); + }; + } + + showLibraryBrowser(assets) { + var _this = this; + + if (assets.length === 0) { + alertify.warning('Your library is empty. Save some assets first!'); + return; + } + + // Group by category + var categories = {}; + assets.forEach(function(asset) { + var cat = asset.category || 'General'; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(asset); + }); + + // Build HTML for the browser + var html = '