From f650ec301972c2b30c5c3c1ac7176ce9225b26c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Jan 2026 00:39:40 +0000 Subject: [PATCH] Add selection tools: Magic Wand, Lasso, Ellipse Select New Selection Tools: - Magic Wand: Click to select by color similarity (like GIMP) - Configurable tolerance (0-100%) - Contiguous or global mode - Shift+Click to add to selection - Lasso: Freehand selection by drawing - Draw around area to select - Shift+Draw to add to selection - Ellipse Select: Draw elliptical/circular selections - Drag to create ellipse - Shift+Drag for perfect circle - Alt+Drag to draw from center Smart Select Improvements: - Fixed copy/cut to layer errors - Added Shift+Click for multi-select (additive selection) - All operations use proper layer action system All selection tools support: - Ctrl+C: Copy selection to new layer - Ctrl+X: Cut selection to new layer - Delete: Delete selected area - Escape: Clear selection - Marching ants animation on selection edge --- frontend/images/icons/ellipse_select.svg | 7 + frontend/images/icons/lasso.svg | 6 + frontend/images/icons/magic_wand.svg | 13 + frontend/src/js/config.js | 22 + frontend/src/js/tools/ellipse_select.js | 657 +++++++++++++++++++++++ frontend/src/js/tools/lasso.js | 597 ++++++++++++++++++++ frontend/src/js/tools/magic_wand.js | 600 +++++++++++++++++++++ frontend/src/js/tools/smart_select.js | 172 +++--- 8 files changed, 1986 insertions(+), 88 deletions(-) create mode 100644 frontend/images/icons/ellipse_select.svg create mode 100644 frontend/images/icons/lasso.svg create mode 100644 frontend/images/icons/magic_wand.svg create mode 100644 frontend/src/js/tools/ellipse_select.js create mode 100644 frontend/src/js/tools/lasso.js create mode 100644 frontend/src/js/tools/magic_wand.js diff --git a/frontend/images/icons/ellipse_select.svg b/frontend/images/icons/ellipse_select.svg new file mode 100644 index 0000000..e910b95 --- /dev/null +++ b/frontend/images/icons/ellipse_select.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/images/icons/lasso.svg b/frontend/images/icons/lasso.svg new file mode 100644 index 0000000..04e99b0 --- /dev/null +++ b/frontend/images/icons/lasso.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/images/icons/magic_wand.svg b/frontend/images/icons/magic_wand.svg new file mode 100644 index 0000000..a44d444 --- /dev/null +++ b/frontend/images/icons/magic_wand.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js index 4ba089e..ca46a26 100644 --- a/frontend/src/js/config.js +++ b/frontend/src/js/config.js @@ -103,6 +103,28 @@ config.TOOLS = [ on_activate: 'on_activate', attributes: {}, }, + { + name: 'magic_wand', + title: 'Magic Wand (Color Select)', + attributes: { + tolerance: { + value: 30, + min: 0, + max: 100, + }, + contiguous: true, + }, + }, + { + name: 'lasso', + title: 'Lasso (Freehand Select)', + attributes: {}, + }, + { + name: 'ellipse_select', + title: 'Ellipse Selection', + attributes: {}, + }, { name: 'brush', attributes: { diff --git a/frontend/src/js/tools/ellipse_select.js b/frontend/src/js/tools/ellipse_select.js new file mode 100644 index 0000000..724c1e1 --- /dev/null +++ b/frontend/src/js/tools/ellipse_select.js @@ -0,0 +1,657 @@ +/** + * Ellipse Selection Tool - Draw elliptical/circular selections + * Drag to create ellipse selection, Shift+Drag for perfect circle + * Hold Alt to draw from center + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Ellipse_select_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'ellipse_select'; + + // Drawing state + this.isDrawing = false; + this.startPoint = null; + this.currentPoint = null; + this.isAdditive = false; + this.isCircle = false; + this.fromCenter = false; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse events + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + document.addEventListener('mousemove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.mouseup(e); + }); + + // Touch events + document.addEventListener('touchstart', function (e) { + _this.mousedown(e); + }); + document.addEventListener('touchmove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('touchend', function (e) { + _this.mouseup(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask || _this.isDrawing) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + this.isDrawing = true; + this.isAdditive = e.shiftKey; + this.isCircle = false; + this.fromCenter = e.altKey; + + this.startPoint = this.getImagePoint(mouse.x, mouse.y); + this.currentPoint = this.startPoint; + + config.need_render = true; + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + var mouse = this.get_mouse_info(e); + + this.currentPoint = this.getImagePoint(mouse.x, mouse.y); + this.isCircle = e.shiftKey; + this.fromCenter = e.altKey; + + config.need_render = true; + } + + mouseup(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + this.isDrawing = false; + + var mouse = this.get_mouse_info(e); + this.currentPoint = this.getImagePoint(mouse.x, mouse.y); + this.isCircle = e.shiftKey; + this.fromCenter = e.altKey; + + // Calculate ellipse bounds + var ellipse = this.calculateEllipse(); + + if (ellipse.radiusX < 2 || ellipse.radiusY < 2) { + alertify.warning('Draw a larger selection'); + config.need_render = true; + return; + } + + // Create mask from ellipse + this.createMaskFromEllipse(ellipse, this.isAdditive); + + this.startPoint = null; + this.currentPoint = null; + } + + getImagePoint(mouseX, mouseY) { + var x = mouseX - config.layer.x; + var y = mouseY - config.layer.y; + + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x))); + y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y))); + + return { x: x, y: y }; + } + + calculateEllipse() { + if (!this.startPoint || !this.currentPoint) { + return { centerX: 0, centerY: 0, radiusX: 0, radiusY: 0 }; + } + + var x1 = this.startPoint.x; + var y1 = this.startPoint.y; + var x2 = this.currentPoint.x; + var y2 = this.currentPoint.y; + + var width = Math.abs(x2 - x1); + var height = Math.abs(y2 - y1); + + // If Shift is held, make it a circle (equal radii) + if (this.isCircle) { + var maxDim = Math.max(width, height); + width = maxDim; + height = maxDim; + } + + var centerX, centerY, radiusX, radiusY; + + if (this.fromCenter) { + // Draw from center + centerX = x1; + centerY = y1; + radiusX = width; + radiusY = height; + } else { + // Draw from corner + var left = Math.min(x1, x2); + var top = Math.min(y1, y2); + + if (this.isCircle) { + // Adjust for circle from corner + if (x2 < x1) left = x1 - width; + if (y2 < y1) top = y1 - height; + } + + centerX = left + width / 2; + centerY = top + height / 2; + radiusX = width / 2; + radiusY = height / 2; + } + + return { + centerX: centerX, + centerY: centerY, + radiusX: radiusX, + radiusY: radiusY + }; + } + + createMaskFromEllipse(ellipse, isAdditive) { + var width = config.layer.width_original; + var height = config.layer.height_original; + + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + + // Draw filled ellipse + maskCtx.fillStyle = 'white'; + maskCtx.beginPath(); + maskCtx.ellipse( + ellipse.centerX, + ellipse.centerY, + ellipse.radiusX, + ellipse.radiusY, + 0, 0, Math.PI * 2 + ); + maskCtx.fill(); + + // Combine with existing mask if additive + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + combinedCtx.drawImage(this.maskCanvas, 0, 0); + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + window.smartSelectMask = this.currentMask; + + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Hold Shift while dragging to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + // Draw current ellipse being drawn + if (this.isDrawing && this.startPoint && this.currentPoint) { + ctx.save(); + + var ellipse = this.calculateEllipse(); + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + ctx.strokeStyle = '#ffff00'; + ctx.lineWidth = 2 / config.ZOOM; + ctx.setLineDash([5, 5]); + ctx.lineDashOffset = -this.marchingAntsOffset; + + ctx.beginPath(); + ctx.ellipse( + config.layer.x + ellipse.centerX * scaleX, + config.layer.y + ellipse.centerY * scaleY, + ellipse.radiusX * scaleX, + ellipse.radiusY * scaleY, + 0, 0, Math.PI * 2 + ); + ctx.stroke(); + + ctx.restore(); + } + + // Draw existing selection + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ffff00' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Ellipse Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Ellipse Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + this.startPoint = null; + this.currentPoint = null; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.isDrawing = false; + this.startPoint = null; + this.currentPoint = null; + return []; + } +} + +export default Ellipse_select_class; diff --git a/frontend/src/js/tools/lasso.js b/frontend/src/js/tools/lasso.js new file mode 100644 index 0000000..3676e6a --- /dev/null +++ b/frontend/src/js/tools/lasso.js @@ -0,0 +1,597 @@ +/** + * Lasso Selection Tool - Freehand selection by drawing + * Draw around an area to select it, Shift+Draw to add to selection + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Lasso_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'lasso'; + + // Drawing state + this.isDrawing = false; + this.points = []; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse events + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + document.addEventListener('mousemove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.mouseup(e); + }); + + // Touch events + document.addEventListener('touchstart', function (e) { + _this.mousedown(e); + }); + document.addEventListener('touchmove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('touchend', function (e) { + _this.mouseup(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask || _this.isDrawing) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + this.isDrawing = true; + this.points = []; + + // Get point relative to image + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + this.points.push(point); + } + + config.need_render = true; + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + var mouse = this.get_mouse_info(e); + + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + this.points.push(point); + } + + config.need_render = true; + } + + mouseup(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + this.isDrawing = false; + + if (this.points.length < 3) { + alertify.warning('Draw a larger selection'); + this.points = []; + config.need_render = true; + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + // Create mask from points + this.createMaskFromPoints(isAdditive); + + this.points = []; + } + + getImagePoint(mouseX, mouseY) { + var x = mouseX - config.layer.x; + var y = mouseY - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + // Clamp to image bounds + x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x))); + y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y))); + + return { x: x, y: y }; + } + + createMaskFromPoints(isAdditive) { + var width = config.layer.width_original; + var height = config.layer.height_original; + + // Create new mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + + // Draw filled polygon + maskCtx.fillStyle = 'white'; + maskCtx.beginPath(); + maskCtx.moveTo(this.points[0].x, this.points[0].y); + for (var i = 1; i < this.points.length; i++) { + maskCtx.lineTo(this.points[i].x, this.points[i].y); + } + maskCtx.closePath(); + maskCtx.fill(); + + // If additive and we have an existing mask, combine them + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + combinedCtx.drawImage(this.maskCanvas, 0, 0); + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + window.smartSelectMask = this.currentMask; + + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Shift+Draw to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + // Draw current drawing path + if (this.isDrawing && this.points.length > 1) { + ctx.save(); + + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + ctx.strokeStyle = '#00ffff'; + ctx.lineWidth = 2 / config.ZOOM; + ctx.setLineDash([5, 5]); + ctx.lineDashOffset = -this.marchingAntsOffset; + + ctx.beginPath(); + ctx.moveTo( + config.layer.x + this.points[0].x * scaleX, + config.layer.y + this.points[0].y * scaleY + ); + for (var i = 1; i < this.points.length; i++) { + ctx.lineTo( + config.layer.x + this.points[i].x * scaleX, + config.layer.y + this.points[i].y * scaleY + ); + } + ctx.stroke(); + + ctx.restore(); + } + + // Draw existing selection + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ffff' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Lasso Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Lasso Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + this.points = []; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.points = []; + this.isDrawing = false; + return []; + } +} + +export default Lasso_class; diff --git a/frontend/src/js/tools/magic_wand.js b/frontend/src/js/tools/magic_wand.js new file mode 100644 index 0000000..24021bb --- /dev/null +++ b/frontend/src/js/tools/magic_wand.js @@ -0,0 +1,600 @@ +/** + * Magic Wand Selection Tool - Selects areas by color similarity + * Click to select similar colors, Shift+Click to add to selection + * Like GIMP's magic wand / fuzzy select tool + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Magic_wand_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'magic_wand'; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse click event for selection + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + // Delete - delete selected area + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + // Escape - clear selection + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + // Ctrl+C - copy to new layer + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + // Ctrl+X - cut to new layer + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + // Start marching ants animation + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + // Get click coordinates relative to the image + var x = mouse.x - config.layer.x; + var y = mouse.y - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + x = Math.round(x); + y = Math.round(y); + + // Make sure click is within image bounds + if (x < 0 || y < 0 || x >= config.layer.width_original || y >= config.layer.height_original) { + alertify.error('Click inside the image'); + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + // Get tool parameters + var params = this.getParams(); + var tolerance = params.tolerance || 30; + var contiguous = params.contiguous !== false; + + // Perform the selection + this.selectByColor(x, y, tolerance, contiguous, isAdditive); + } + + /** + * Select pixels by color similarity using flood fill algorithm + */ + selectByColor(startX, startY, tolerance, contiguous, isAdditive) { + var layer = config.layer; + + // Get the layer's image data + var srcCanvas = document.createElement('canvas'); + srcCanvas.width = layer.width_original; + srcCanvas.height = layer.height_original; + var srcCtx = srcCanvas.getContext('2d'); + srcCtx.drawImage(layer.link, 0, 0); + + var imageData = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height); + var data = imageData.data; + var width = srcCanvas.width; + var height = srcCanvas.height; + + // Create mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + var maskImageData = maskCtx.createImageData(width, height); + var maskData = maskImageData.data; + + // Get the color at the clicked point + var startIdx = (startY * width + startX) * 4; + var targetColor = { + r: data[startIdx], + g: data[startIdx + 1], + b: data[startIdx + 2], + a: data[startIdx + 3] + }; + + // Convert tolerance to 0-255 range + var sens = tolerance * 255 / 100; + + if (contiguous) { + // Flood fill - only connected pixels + var visited = new Uint8Array(width * height); + var stack = [[startX, startY]]; + var dx = [0, -1, +1, 0]; + var dy = [-1, 0, 0, +1]; + + while (stack.length > 0) { + var point = stack.pop(); + var px = point[0]; + var py = point[1]; + + if (px < 0 || py < 0 || px >= width || py >= height) continue; + + var idx = py * width + px; + if (visited[idx]) continue; + visited[idx] = 1; + + var i = idx * 4; + + // Check color similarity + if (Math.abs(data[i] - targetColor.r) <= sens && + Math.abs(data[i + 1] - targetColor.g) <= sens && + Math.abs(data[i + 2] - targetColor.b) <= sens && + Math.abs(data[i + 3] - targetColor.a) <= sens) { + + // Add to mask (white = selected) + maskData[i] = 255; + maskData[i + 1] = 255; + maskData[i + 2] = 255; + maskData[i + 3] = 255; + + // Add neighbors to stack + for (var d = 0; d < 4; d++) { + stack.push([px + dx[d], py + dy[d]]); + } + } + } + } else { + // Global - all matching pixels regardless of connection + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + + if (Math.abs(data[i] - targetColor.r) <= sens && + Math.abs(data[i + 1] - targetColor.g) <= sens && + Math.abs(data[i + 2] - targetColor.b) <= sens && + Math.abs(data[i + 3] - targetColor.a) <= sens) { + + maskData[i] = 255; + maskData[i + 1] = 255; + maskData[i + 2] = 255; + maskData[i + 3] = 255; + } + } + } + } + + maskCtx.putImageData(maskImageData, 0, 0); + + // If additive and we have an existing mask, combine them + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + // Draw existing mask + combinedCtx.drawImage(this.maskCanvas, 0, 0); + + // Add new mask + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + // Store globally for other tools + window.smartSelectMask = this.currentMask; + + // Calculate bounds and extract edge + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Shift+Click to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay on non-selected areas + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ff00ff' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Magic Wand Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + 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(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Magic Wand Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + return []; + } +} + +export default Magic_wand_class; diff --git a/frontend/src/js/tools/smart_select.js b/frontend/src/js/tools/smart_select.js index 0e80c3e..f25c2ab 100644 --- a/frontend/src/js/tools/smart_select.js +++ b/frontend/src/js/tools/smart_select.js @@ -1,6 +1,7 @@ /** * Smart Select Tool - Uses SAM (Segment Anything Model) for AI-powered selection * Click on any object to automatically select it + * Shift+Click to add to existing selection (multi-select) * Supports: Copy to layer, Cut to layer, Delete selection, AI Inpaint */ @@ -31,10 +32,9 @@ class Smart_select_class extends Base_tools_class { // Marching ants animation this.marchingAntsOffset = 0; - this.animationFrame = null; - // Contour path for drawing the mask outline - this.contourPath = null; + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; } load() { @@ -81,18 +81,7 @@ class Smart_select_class extends Base_tools_class { startMarchingAnts() { var _this = this; - var animate = function() { - _this.marchingAntsOffset++; - if (_this.marchingAntsOffset > 16) { - _this.marchingAntsOffset = 0; - } - if (_this.currentMask) { - config.need_render = true; - } - _this.animationFrame = requestAnimationFrame(animate); - }; - - // Slower animation - every 100ms + // Animate every 100ms for smooth marching ants setInterval(function() { if (_this.currentMask) { _this.marchingAntsOffset++; @@ -138,6 +127,9 @@ class Smart_select_class extends Base_tools_class { return; } + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + this.isProcessing = true; alertify.message('AI is analyzing the image...'); @@ -148,10 +140,14 @@ class Smart_select_class extends Base_tools_class { // Call SAM API var result = await apiService.smartSelect(imageData, Math.round(x), Math.round(y)); - // Apply the mask as selection - this.applyMask(result.mask, result.bbox); + // Apply the mask as selection (additive if Shift is held) + this.applyMask(result.mask, result.bbox, isAdditive); - alertify.success('Selection complete! Use Ctrl+C to copy, Ctrl+X to cut, Delete to remove, or AI Inpaint to edit.'); + if (isAdditive && this.currentMask) { + alertify.success('Added to selection! Shift+Click to add more.'); + } else { + alertify.success('Selection complete! Shift+Click to add more, Ctrl+C to copy, Ctrl+X to cut.'); + } } catch (error) { console.error('Smart select error:', error); @@ -182,19 +178,39 @@ class Smart_select_class extends Base_tools_class { * Apply the SAM mask as a selection * @param {string} maskBase64 - Base64 encoded mask image * @param {Object} bbox - Bounding box {x, y, width, height} + * @param {boolean} isAdditive - If true, add to existing selection */ - applyMask(maskBase64, bbox) { + applyMask(maskBase64, bbox, isAdditive) { var _this = this; // Create mask image var maskImage = new Image(); maskImage.onload = function() { - // Store mask for later use by inpaint tool - _this.maskCanvas = document.createElement('canvas'); - _this.maskCanvas.width = config.layer.width_original; - _this.maskCanvas.height = config.layer.height_original; - var maskCtx = _this.maskCanvas.getContext('2d'); - maskCtx.drawImage(maskImage, 0, 0); + // Create new mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = config.layer.width_original; + newMaskCanvas.height = config.layer.height_original; + var newMaskCtx = newMaskCanvas.getContext('2d'); + newMaskCtx.drawImage(maskImage, 0, 0); + + // If additive and we have an existing mask, combine them + if (isAdditive && _this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = config.layer.width_original; + combinedCanvas.height = config.layer.height_original; + var combinedCtx = combinedCanvas.getContext('2d'); + + // Draw existing mask + combinedCtx.drawImage(_this.maskCanvas, 0, 0); + + // Add new mask using 'lighter' composite to combine white areas + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + _this.maskCanvas = combinedCanvas; + } else { + _this.maskCanvas = newMaskCanvas; + } _this.currentMask = { canvas: _this.maskCanvas, @@ -325,10 +341,6 @@ class Smart_select_class extends Base_tools_class { ctx.save(); - // Scale to match layer - var scaleX = config.layer.width / config.layer.width_original; - var scaleY = config.layer.height / config.layer.height_original; - // Draw semi-transparent overlay on non-selected areas var inverseCanvas = document.createElement('canvas'); inverseCanvas.width = this.maskCanvas.width; @@ -358,28 +370,15 @@ class Smart_select_class extends Base_tools_class { antsCanvas.height = this.maskCanvas.height; var antsCtx = antsCanvas.getContext('2d'); - // Draw the edge in white (visible part of marching ants) + // Draw the edge antsCtx.drawImage(this.edgeCanvas, 0, 0); - // Apply marching ants pattern using composite + // Apply marching ants color using composite antsCtx.globalCompositeOperation = 'source-in'; - // Create marching ants pattern (alternating black and white) - var pattern = antsCtx.createLinearGradient(0, 0, 16, 16); - var offset = this.marchingAntsOffset / 16; - - // Create dashed pattern - for (var i = 0; i < 2; i++) { - var pos1 = ((i * 0.5) + offset) % 1; - var pos2 = ((i * 0.5 + 0.25) + offset) % 1; - - if (pos1 < pos2) { - pattern.addColorStop(pos1, '#00ff00'); - pattern.addColorStop(pos2, '#00ff00'); - } - } - - antsCtx.fillStyle = '#00ff00'; + // Alternate color based on animation offset + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ff00' : '#ffffff'; + antsCtx.fillStyle = color; antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); // Draw the marching ants outline @@ -388,20 +387,6 @@ class Smart_select_class extends Base_tools_class { config.layer.x, config.layer.y, config.layer.width, config.layer.height ); - - // Also draw a second pass with offset for alternating colors - var antsCanvas2 = document.createElement('canvas'); - antsCanvas2.width = this.maskCanvas.width; - antsCanvas2.height = this.maskCanvas.height; - var antsCtx2 = antsCanvas2.getContext('2d'); - - // Dilate the edge slightly for the black outline - antsCtx2.drawImage(this.edgeCanvas, 0, 0); - antsCtx2.globalCompositeOperation = 'source-in'; - - // Alternate pattern - antsCtx2.fillStyle = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ffffff' : '#000000'; - antsCtx2.fillRect(0, 0, antsCanvas2.width, antsCanvas2.height); } ctx.restore(); @@ -422,6 +407,13 @@ class Smart_select_class extends Base_tools_class { return; } + // Get the bounds of the selection + var bounds = this.selectionBounds; + if (!bounds || !bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + // Create canvas with just the selected pixels var canvas = document.createElement('canvas'); canvas.width = layer.width_original; @@ -435,17 +427,16 @@ class Smart_select_class extends Base_tools_class { ctx.globalCompositeOperation = 'destination-in'; ctx.drawImage(this.maskCanvas, 0, 0); - // Get the bounds of the selection to crop the canvas - var bounds = this.selectionBounds; - if (!bounds) { - alertify.error('Invalid selection bounds'); + // Crop to selection bounds + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); return; } - // Crop to selection bounds var croppedCanvas = document.createElement('canvas'); - var cropWidth = bounds.origMaxX - bounds.origMinX; - var cropHeight = bounds.origMaxY - bounds.origMinY; croppedCanvas.width = cropWidth; croppedCanvas.height = cropHeight; var croppedCtx = croppedCanvas.getContext('2d'); @@ -460,12 +451,12 @@ class Smart_select_class extends Base_tools_class { var scaleX = layer.width / layer.width_original; var scaleY = layer.height / layer.height_original; - // Create new layer with the selection + // Create new layer with the selection - use data as dataURL string var params = { x: Math.round(layer.x + bounds.origMinX * scaleX), y: Math.round(layer.y + bounds.origMinY * scaleY), - width: Math.round(cropWidth * scaleX), - height: Math.round(cropHeight * scaleY), + width: cropWidth, + height: cropHeight, width_original: cropWidth, height_original: cropHeight, type: 'image', @@ -475,7 +466,7 @@ class Smart_select_class extends Base_tools_class { app.State.do_action( new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ - new app.Actions.Insert_layer_action(params, false) + new app.Actions.Insert_layer_action(params) ]) ); @@ -497,7 +488,14 @@ class Smart_select_class extends Base_tools_class { return; } - // First copy to new layer + // Get the bounds of the selection + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + // Create canvas with just the selected pixels var canvas = document.createElement('canvas'); canvas.width = layer.width_original; canvas.height = layer.height_original; @@ -510,17 +508,16 @@ class Smart_select_class extends Base_tools_class { ctx.globalCompositeOperation = 'destination-in'; ctx.drawImage(this.maskCanvas, 0, 0); - // Get the bounds of the selection - var bounds = this.selectionBounds; - if (!bounds) { - alertify.error('Invalid selection bounds'); + // Crop to selection bounds + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); return; } - // Crop to selection bounds var croppedCanvas = document.createElement('canvas'); - var cropWidth = bounds.origMaxX - bounds.origMinX; - var cropHeight = bounds.origMaxY - bounds.origMinY; croppedCanvas.width = cropWidth; croppedCanvas.height = cropHeight; var croppedCtx = croppedCanvas.getContext('2d'); @@ -539,8 +536,8 @@ class Smart_select_class extends Base_tools_class { var params = { x: Math.round(layer.x + bounds.origMinX * scaleX), y: Math.round(layer.y + bounds.origMinY * scaleY), - width: Math.round(cropWidth * scaleX), - height: Math.round(cropHeight * scaleY), + width: cropWidth, + height: cropHeight, width_original: cropWidth, height_original: cropHeight, type: 'image', @@ -548,7 +545,7 @@ class Smart_select_class extends Base_tools_class { data: croppedCanvas.toDataURL('image/png') }; - // Now delete from original - create canvas with hole + // Create canvas with hole where selection was var holeCanvas = document.createElement('canvas'); holeCanvas.width = layer.width_original; holeCanvas.height = layer.height_original; @@ -561,11 +558,11 @@ class Smart_select_class extends Base_tools_class { holeCtx.globalCompositeOperation = 'destination-out'; holeCtx.drawImage(this.maskCanvas, 0, 0); - // Execute both actions + // Execute both actions - update original layer, then insert new layer app.State.do_action( new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ - new app.Actions.Update_layer_image_action(holeCanvas), - new app.Actions.Insert_layer_action(params, false) + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) ]) ); @@ -605,7 +602,7 @@ class Smart_select_class extends Base_tools_class { app.State.do_action( new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ - new app.Actions.Update_layer_image_action(holeCanvas) + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) ]) ); @@ -623,7 +620,6 @@ class Smart_select_class extends Base_tools_class { this.maskCanvas = null; this.edgeCanvas = null; this.selectionBounds = null; - this.contourPath = null; window.smartSelectMask = null; config.need_render = true; this.Base_layers.render();