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 = '
'; + html += '
'; + + for (var cat in categories) { + html += '
'; + html += '

' + cat + '

'; + html += '
'; + + categories[cat].forEach(function(asset) { + html += '
'; + html += '' + asset.name + ''; + html += '
' + asset.name + '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + }); + + html += '
'; + } + + html += '
'; + + // Show in dialog + var settings = { + title: 'My Library (' + assets.length + ' assets)', + params: [], + html: html, + className: 'library-dialog', + on_load: function(el) { + // Add click handlers + el.querySelectorAll('.insert-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + _this.insertAsset(id); + _this.POP.hide(); + }); + }); + + el.querySelectorAll('.delete-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + if (confirm('Delete this asset?')) { + _this.deleteAsset(id, function() { + alertify.success('Asset deleted'); + _this.POP.hide(); + }); + } + }); + }); + + // Double-click to insert + el.querySelectorAll('.library-item').forEach(function(item) { + item.addEventListener('dblclick', function() { + var id = parseInt(this.dataset.id); + _this.insertAsset(id); + _this.POP.hide(); + }); + }); + } + }; + + this.POP.show(settings); + } + + insertAsset(id) { + var _this = this; + + var transaction = this.db.transaction([STORE_NAME], 'readonly'); + var store = transaction.objectStore(STORE_NAME); + var request = store.get(id); + + request.onsuccess = function(event) { + var asset = event.target.result; + if (asset) { + _this.createLayerFromAsset(asset); + } + }; + } + + createLayerFromAsset(asset) { + var _this = this; + + var img = new Image(); + img.onload = function() { + // Insert as new layer + var params = { + x: Math.round((config.WIDTH - asset.width) / 2), + y: Math.round((config.HEIGHT - asset.height) / 2), + width: asset.width, + height: asset.height, + width_original: asset.width, + height_original: asset.height, + type: 'image', + name: asset.name, + data: asset.data + }; + + app.State.do_action( + new app.Actions.Bundle_action('insert_library_asset', 'Insert Library Asset', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Inserted "' + asset.name + '"'); + }; + img.src = asset.data; + } + + deleteAsset(id, callback) { + var transaction = this.db.transaction([STORE_NAME], 'readwrite'); + var store = transaction.objectStore(STORE_NAME); + var request = store.delete(id); + + request.onsuccess = function() { + if (callback) callback(); + }; + } + + /** + * Export library to JSON file (backup) + */ + export_library() { + var _this = this; + + this.getAllAssets(function(assets) { + if (assets.length === 0) { + alertify.warning('Library is empty'); + return; + } + + var data = JSON.stringify(assets, null, 2); + var blob = new Blob([data], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + + var a = document.createElement('a'); + a.href = url; + a.download = 'my-library-backup.json'; + a.click(); + + URL.revokeObjectURL(url); + alertify.success('Library exported!'); + }); + } + + /** + * Import library from JSON file + */ + import_library() { + var _this = this; + + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = function(e) { + var file = e.target.files[0]; + if (!file) return; + + var reader = new FileReader(); + reader.onload = function(event) { + try { + var assets = JSON.parse(event.target.result); + + if (!Array.isArray(assets)) { + alertify.error('Invalid library file'); + return; + } + + var imported = 0; + assets.forEach(function(asset) { + // Remove id so it gets auto-assigned + delete asset.id; + _this.saveAsset(asset, function() { + imported++; + if (imported === assets.length) { + alertify.success('Imported ' + imported + ' assets!'); + } + }); + }); + + } catch (err) { + alertify.error('Error parsing library file'); + console.error(err); + } + }; + reader.readAsText(file); + }; + + input.click(); + } +} + +export default File_my_library_class; diff --git a/frontend/src/js/modules/image/selection_effects.js b/frontend/src/js/modules/image/selection_effects.js new file mode 100644 index 0000000..724824a --- /dev/null +++ b/frontend/src/js/modules/image/selection_effects.js @@ -0,0 +1,353 @@ +/** + * Selection Effects - Apply effects only to the selected area + * Works with any selection tool (Smart Select, Brush Select, Magic Wand, Lasso, Ellipse) + * Useful for CNC depth maps where you want to modify specific objects + */ + +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 Image_selection_effects_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + /** + * Check if there's a valid selection + */ + hasSelection() { + return window.smartSelectMask && window.smartSelectMask.canvas; + } + + /** + * Invert colors only in the selected area + */ + invert_selection() { + var _this = this; + + if (!this.hasSelection()) { + alertify.error('No selection. Use a selection tool first (Smart Select, Brush Select, Magic Wand, etc.)'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Invert Selection', + preview: true, + params: [ + { + name: "strength", + title: "Strength:", + value: 100, + range: [0, 100] + }, + { + name: "preserve_luminosity", + title: "Preserve Luminosity:", + value: false + } + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_invert(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_invert(params); + }, + }; + this.POP.show(settings); + } + + apply_invert(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var strength = (params.strength || 100) / 100; + var preserveLuminosity = params.preserve_luminosity || false; + + // Get mask data + var maskCanvas = window.smartSelectMask.canvas; + var maskCtx = maskCanvas.getContext('2d'); + + // Scale mask to match current canvas size if needed + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; // 0-1 range + + if (maskValue > 0.5) { // Inside selection + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + + // Invert colors + var newR = 255 - r; + var newG = 255 - g; + var newB = 255 - b; + + if (preserveLuminosity) { + // Calculate original and new luminosity + var oldLum = 0.299 * r + 0.587 * g + 0.114 * b; + var newLum = 0.299 * newR + 0.587 * newG + 0.114 * newB; + + // Adjust to preserve luminosity + if (newLum > 0) { + var ratio = oldLum / newLum; + newR = Math.min(255, newR * ratio); + newG = Math.min(255, newG * ratio); + newB = Math.min(255, newB * ratio); + } + } + + // Apply strength (blend between original and inverted) + data[i] = Math.round(r + (newR - r) * strength); + data[i + 1] = Math.round(g + (newG - g) * strength); + data[i + 2] = Math.round(b + (newB - b) * strength); + } + } + + return imageData; + } + + save_invert(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_invert(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + /** + * Adjust brightness/contrast only in the selected area + */ + adjust_selection() { + var _this = this; + + if (!this.hasSelection()) { + 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: 'Adjust Selection', + preview: true, + params: [ + {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]}, + {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]}, + {name: "gamma", title: "Gamma:", value: 1, range: [0.1, 3], step: 0.1}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_adjust(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_adjust(params); + }, + }; + this.POP.show(settings); + } + + apply_adjust(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var brightness = (params.brightness || 0) * 2.55; + var contrast = (params.contrast || 0) / 100; + var gamma = params.gamma || 1; + + var factor = (1 + contrast); + + // Get mask data + var maskCanvas = window.smartSelectMask.canvas; + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; + + if (maskValue > 0.5) { + for (var c = 0; c < 3; c++) { + var value = data[i + c]; + + // Apply brightness + value += brightness; + + // Apply contrast + value = ((value - 128) * factor) + 128; + + // Apply gamma + value = 255 * Math.pow(value / 255, 1 / gamma); + + data[i + c] = Math.max(0, Math.min(255, Math.round(value))); + } + } + } + + return imageData; + } + + save_adjust(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_adjust(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + /** + * Convert selection to greyscale (useful for depth maps) + */ + greyscale_selection() { + var _this = this; + + if (!this.hasSelection()) { + 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: 'Greyscale Selection', + preview: true, + params: [ + { + name: "method", + title: "Method:", + values: ["Luminosity", "Average", "Lightness"], + value: "Luminosity" + }, + {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_selection(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_greyscale_selection(params); + }, + }; + this.POP.show(settings); + } + + apply_greyscale_selection(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var method = params.method || "Luminosity"; + var invert = params.invert || false; + + var maskCanvas = window.smartSelectMask.canvas; + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; + + if (maskValue > 0.5) { + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + var grey; + + switch (method) { + case "Luminosity": + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + break; + case "Average": + grey = (r + g + b) / 3; + break; + case "Lightness": + grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + break; + default: + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + if (invert) { + grey = 255 - grey; + } + + grey = Math.max(0, Math.min(255, Math.round(grey))); + + data[i] = grey; + data[i + 1] = grey; + data[i + 2] = grey; + } + } + + return imageData; + } + + save_greyscale_selection(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_selection(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } +} + +export default Image_selection_effects_class; diff --git a/frontend/src/js/modules/layer/scale.js b/frontend/src/js/modules/layer/scale.js new file mode 100644 index 0000000..34a8b50 --- /dev/null +++ b/frontend/src/js/modules/layer/scale.js @@ -0,0 +1,174 @@ +/** + * Layer Scale module - Scale individual layers (like GIMP's Scale Layer) + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Pica from './../../../../node_modules/pica/dist/pica.js'; + +var instance = null; + +class Layer_scale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.pica = Pica(); + } + + scale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('Please convert layer to raster first (Layer > Raster)'); + return; + } + + var currentWidth = config.layer.width; + var currentHeight = config.layer.height; + var aspectRatio = currentWidth / currentHeight; + + var settings = { + title: 'Scale Layer', + params: [ + {name: "width", title: "Width:", value: currentWidth, placeholder: currentWidth}, + {name: "height", title: "Height:", value: currentHeight, placeholder: currentHeight}, + {name: "width_percent", title: "Width %:", value: 100, placeholder: 100}, + {name: "height_percent", title: "Height %:", value: 100, placeholder: 100}, + {name: "maintain_aspect", title: "Maintain Aspect Ratio:", value: true}, + {name: "interpolation", title: "Interpolation:", values: ["Lanczos (Best)", "Bilinear", "Nearest"]}, + ], + on_change: function(params) { + // Auto-adjust to maintain aspect ratio if enabled + if (params.maintain_aspect) { + var widthInput = document.getElementById("pop_data_width"); + var heightInput = document.getElementById("pop_data_height"); + var widthPercentInput = document.getElementById("pop_data_width_percent"); + var heightPercentInput = document.getElementById("pop_data_height_percent"); + + // This is simplified - in practice you'd track which field changed + } + }, + on_finish: function (params) { + _this.do_scale(params); + }, + }; + this.POP.show(settings); + } + + async do_scale(params) { + var layer = config.layer; + + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var currentWidth = layer.width; + var currentHeight = layer.height; + + // Calculate new dimensions + var newWidth, newHeight; + + if (params.width && params.width != currentWidth) { + newWidth = parseInt(params.width); + if (params.maintain_aspect) { + newHeight = Math.round(newWidth / (currentWidth / currentHeight)); + } else { + newHeight = params.height ? parseInt(params.height) : currentHeight; + } + } else if (params.height && params.height != currentHeight) { + newHeight = parseInt(params.height); + if (params.maintain_aspect) { + newWidth = Math.round(newHeight * (currentWidth / currentHeight)); + } else { + newWidth = params.width ? parseInt(params.width) : currentWidth; + } + } else if (params.width_percent && params.width_percent != 100) { + newWidth = Math.round(currentWidth * params.width_percent / 100); + if (params.maintain_aspect) { + newHeight = Math.round(currentHeight * params.width_percent / 100); + } else { + newHeight = Math.round(currentHeight * (params.height_percent || 100) / 100); + } + } else if (params.height_percent && params.height_percent != 100) { + newHeight = Math.round(currentHeight * params.height_percent / 100); + if (params.maintain_aspect) { + newWidth = Math.round(currentWidth * params.height_percent / 100); + } else { + newWidth = Math.round(currentWidth * (params.width_percent || 100) / 100); + } + } else { + newWidth = parseInt(params.width) || currentWidth; + newHeight = parseInt(params.height) || currentHeight; + } + + if (newWidth <= 0 || newHeight <= 0) { + alertify.error('Invalid dimensions'); + return; + } + + if (newWidth === currentWidth && newHeight === currentHeight) { + alertify.warning('No change in size'); + return; + } + + // Get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var ctx = canvas.getContext("2d"); + + // Create destination canvas + var destCanvas = document.createElement('canvas'); + destCanvas.width = newWidth; + destCanvas.height = newHeight; + var destCtx = destCanvas.getContext('2d'); + + // Perform resize based on interpolation method + if (params.interpolation === "Lanczos (Best)") { + await this.pica.resize(canvas, destCanvas, { alpha: true }); + } else if (params.interpolation === "Bilinear") { + destCtx.imageSmoothingEnabled = true; + destCtx.imageSmoothingQuality = 'high'; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } else { + // Nearest neighbor + destCtx.imageSmoothingEnabled = false; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } + + // Calculate new position (keep center in same place) + var centerX = layer.x + layer.width / 2; + var centerY = layer.y + layer.height / 2; + var newX = Math.round(centerX - newWidth / 2); + var newY = Math.round(centerY - newHeight / 2); + + // Apply the changes + app.State.do_action( + new app.Actions.Bundle_action('scale_layer', 'Scale Layer', [ + new app.Actions.Update_layer_image_action(destCanvas, layer.id), + new app.Actions.Update_layer_action(layer.id, { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + width_original: newWidth, + height_original: newHeight + }) + ]) + ); + + alertify.success('Layer scaled to ' + newWidth + 'x' + newHeight); + } +} + +export default Layer_scale_class;