Add layer scaling, greyscale effect, and context menu

- Fix onnxruntime version (1.15.1) to avoid executable stack issue
- Add Layer Scale module (Layer > Scale Layer or 'S' button)
- Add Greyscale effect with multiple methods (luminosity, average, etc.)
- Add right-click context menu on layers with common operations
- Add Scale button to layers panel toolbar
This commit is contained in:
Claude
2026-01-27 12:51:11 +00:00
parent d9fea955fc
commit 1be13d3620
6 changed files with 529 additions and 1 deletions
+2 -1
View File
@@ -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
+45
View File
@@ -518,6 +518,51 @@ 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;
}
.sidebar_right .label{
display: inline-block;
}
+10
View File
@@ -326,6 +326,11 @@ const menuDefinition = [
name: 'Convert to Raster',
target: 'layer/raster.raster'
},
{
name: 'Scale Layer',
ellipsis: true,
target: 'layer/scale.scale'
},
{
divider: true
},
@@ -480,6 +485,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,
+141
View File
@@ -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 = `
<button type="button" class="layer_add trn" id="insert_layer" title="Insert new layer">+</button>
<button type="button" class="layer_duplicate trn" id="layer_duplicate" title="Duplicate layer">D</button>
<button type="button" class="layer_raster trn" id="layer_raster" title="Convert layer to raster">R</button>
<button type="button" class="layer_scale trn" id="layer_scale" title="Scale layer">S</button>
<button type="button" class="layers_arrow trn" title="Move layer down" id="layer_down">&darr;</button>
<button type="button" class="layers_arrow trn" title="Move layer up" id="layer_up">&uarr;</button>
<div class="layers_list" id="layers"></div>
<div class="layer_context_menu" id="layer_context_menu"></div>
`;
/**
@@ -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 = '<ul class="context-menu-list">';
for (var i = 0; i < menuItems.length; i++) {
var item = menuItems[i];
if (item.label === '---') {
html += '<li class="separator"></li>';
} else {
html += '<li data-action="' + item.action + '">' + item.label + '</li>';
}
}
html += '</ul>';
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;
}
}
/**
@@ -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;
+174
View File
@@ -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;