Add layer visibility toggle and icon support

Introduces a 'visible' property to layers and updates all relevant logic to support toggling layer visibility. Adds visibility toggle icons to the layers panel using a new IconLoader utility, with SVG and fallback canvas icons. Updates rendering, selection, and batch preview logic to respect layer visibility. Also improves blend mode menu UI and ensures new/pasted layers are always added on top with correct z-index.
This commit is contained in:
Dariusz L
2025-07-24 19:10:17 +02:00
parent 2778b8df9f
commit 3b1a69041c
19 changed files with 1159 additions and 62 deletions

View File

@@ -126,7 +126,10 @@ export class BatchPreviewManager {
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.remove('primary');
toggleBtn.textContent = "Hide Mask";
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
if (iconContainer) {
iconContainer.style.opacity = '0.5';
}
}
this.canvas.render();
}
@@ -143,6 +146,10 @@ export class BatchPreviewManager {
this.worldX -= menuWidthInWorld / 2;
this.worldY += paddingInWorld;
}
// Hide all batch layers initially, then show only the first one
this.layers.forEach((layer) => {
layer.visible = false;
});
this._update();
}
hide() {
@@ -161,11 +168,21 @@ export class BatchPreviewManager {
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
if (toggleBtn) {
toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask";
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
if (iconContainer) {
iconContainer.style.opacity = '1';
}
}
}
this.maskWasVisible = false;
this.canvas.layers.forEach((l) => l.visible = true);
// Only make visible the layers that were part of the batch preview
this.layers.forEach((layer) => {
layer.visible = true;
});
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
}
navigate(direction) {
@@ -203,11 +220,18 @@ export class BatchPreviewManager {
_focusOnLayer(layer) {
if (!layer)
return;
log.debug(`Focusing on layer ${layer.id}`);
// Move the selected layer to the top of the layer stack
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
// Hide all batch layers first
this.layers.forEach((l) => {
l.visible = false;
});
// Show only the current layer
layer.visible = true;
this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
}
}

View File

@@ -319,6 +319,26 @@ export class Canvas {
layer.y -= finalY;
});
this.maskTool.updatePosition(-finalX, -finalY);
// Update batch preview managers like in canvas resize/move operations
if (this.pendingBatchContext) {
this.pendingBatchContext.outputArea.x -= finalX;
this.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.pendingBatchContext.spawnPosition.x -= finalX;
this.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during shape definition:", this.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.batchPreviewManagers && this.batchPreviewManagers.length > 0) {
this.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.viewport.x -= finalX;
this.viewport.y -= finalY;
this.saveState();

View File

@@ -451,7 +451,8 @@ export class CanvasIO {
originalWidth: image.width,
originalHeight: image.height,
blendMode: 'normal',
opacity: 1
opacity: 1,
visible: true
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
@@ -610,6 +611,7 @@ export class CanvasIO {
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
visible: true,
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);

View File

@@ -38,6 +38,10 @@ export class CanvasLayers {
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
}
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
const layer = {
id: generateUUID(),
image: image,
@@ -50,9 +54,10 @@ export class CanvasLayers {
originalWidth: image.width,
originalHeight: image.height,
rotation: 0,
zIndex: this.canvas.layers.length,
zIndex: maxZIndex + 1, // Always add new layer on top
blendMode: 'normal',
opacity: 1,
visible: true,
...layerProps
};
if (layer.mask) {
@@ -189,12 +194,16 @@ export class CanvasLayers {
const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition;
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
this.internalClipboard.forEach((clipboardLayer) => {
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
this.internalClipboard.forEach((clipboardLayer, index) => {
const newLayer = {
...clipboardLayer,
x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + offsetY,
zIndex: this.canvas.layers.length
zIndex: maxZIndex + 1 + index // Ensure pasted layers maintain their relative order
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
@@ -314,6 +323,9 @@ export class CanvasLayers {
getLayerAtPosition(worldX, worldY) {
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
// Skip invisible layers
if (!layer.visible)
continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const dx = worldX - centerX;
@@ -354,7 +366,11 @@ export class CanvasLayers {
}
_drawLayers(ctx, layers, options = {}) {
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
sortedLayers.forEach(layer => {
if (layer.visible) {
this._drawLayer(ctx, layer, options);
}
});
}
drawLayersToContext(ctx, layers, options = {}) {
this._drawLayers(ctx, layers, options);
@@ -491,8 +507,46 @@ export class CanvasLayers {
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
`;
titleBar.textContent = 'Blend Mode';
const titleText = document.createElement('span');
titleText.textContent = 'Blend Mode';
titleText.style.cssText = `
flex: 1;
cursor: move;
`;
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#4a4a4a';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = 'transparent';
};
closeButton.onclick = (e) => {
e.stopPropagation();
this.closeBlendModeMenu();
};
titleBar.appendChild(titleText);
titleBar.appendChild(closeButton);
const content = document.createElement('div');
content.style.cssText = `padding: 5px;`;
menu.appendChild(titleBar);
@@ -823,7 +877,8 @@ export class CanvasLayers {
rotation: 0,
zIndex: minZIndex,
blendMode: 'normal',
opacity: 1
opacity: 1,
visible: true
};
this.canvas.layers = this.canvas.layers.filter((layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));
this.canvas.layers.push(fusedLayer);

View File

@@ -1,4 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel {
constructor(canvas) {
@@ -14,8 +15,106 @@ export class CanvasLayersPanel {
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this);
// Preload icons
this.initializeIcons();
log.info('CanvasLayersPanel initialized');
}
async initializeIcons() {
try {
await iconLoader.preloadToolIcons();
log.debug('Icons preloaded successfully');
}
catch (error) {
log.warn('Failed to preload icons, using fallbacks:', error);
}
}
createIconElement(toolName, size = 16) {
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.cssText = `
width: ${size}px;
height: ${size}px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(icon, 0, 0, size, size);
}
iconContainer.appendChild(canvas);
}
}
else {
// Fallback text
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
}
return iconContainer;
}
createVisibilityIcon(isVisible) {
if (isVisible) {
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
}
else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.globalAlpha = 0.3;
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
}
else {
// Fallback
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
}
return iconContainer;
}
}
createPanelStructure() {
this.container = document.createElement('div');
this.container.className = 'layers-panel';
@@ -24,7 +123,7 @@ export class CanvasLayersPanel {
<div class="layers-panel-header">
<span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
</div>
</div>
<div class="layers-container" id="layers-container">
@@ -231,6 +330,23 @@ export class CanvasLayersPanel {
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
@@ -239,6 +355,11 @@ export class CanvasLayersPanel {
if (!this.container)
return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
// Add delete icon to button
if (deleteBtn) {
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
deleteBtn.appendChild(deleteIcon);
}
deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
@@ -280,9 +401,16 @@ export class CanvasLayersPanel {
layer.name = this.ensureUniqueName(layer.name, layer);
}
layerRow.innerHTML = `
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
<div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`;
// Add visibility icon
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
if (visibilityToggle) {
const visibilityIcon = this.createVisibilityIcon(layer.visible);
visibilityToggle.appendChild(visibilityIcon);
}
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
if (thumbnailContainer) {
this.generateThumbnail(layer, thumbnailContainer);
@@ -328,6 +456,15 @@ export class CanvasLayersPanel {
this.startEditingLayerName(nameElement, layer);
}
});
// Add visibility toggle event listener
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
if (visibilityToggle) {
visibilityToggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleLayerVisibility(layer);
});
}
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
@@ -401,6 +538,19 @@ export class CanvasLayersPanel {
} while (existingNames.includes(uniqueName));
return uniqueName;
}
toggleLayerVisibility(layer) {
layer.visible = !layer.visible;
// If layer became invisible and is selected, deselect it
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.updateSelection(newSelection);
}
this.canvas.render();
this.canvas.requestSaveState();
// Update the eye icon in the panel
this.renderLayers();
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
}
deleteSelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion');

View File

@@ -46,7 +46,7 @@ export class CanvasRenderer {
this.drawGrid(ctx);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image)
if (!layer.image || !layer.visible)
return;
ctx.save();
const currentTransform = ctx.getTransform();
@@ -171,7 +171,7 @@ export class CanvasRenderer {
renderLayerInfo(ctx) {
if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
if (!layer.image)
if (!layer.image || !layer.visible)
return;
const layerIndex = this.canvas.layers.indexOf(layer);
const currentWidth = Math.round(layer.width);

View File

@@ -40,7 +40,8 @@ export class CanvasSelection {
*/
updateSelection(newSelection) {
const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || [];
// Filter out invisible layers from selection
this.selectedLayers = (newSelection || []).filter((layer) => layer.visible !== false);
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length ||

View File

@@ -7,6 +7,7 @@ import { Canvas } from "./Canvas.js";
import { clearAllCanvasStates } from "./db.js";
import { ImageCache } from "./ImageCache.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
const log = createModuleLogger('Canvas_view');
async function createCanvasWidget(node, widget, app) {
@@ -371,19 +372,32 @@ async function createCanvasWidget(node, widget, app) {
$el("div.painter-button-group", { id: "mask-controls" }, [
$el("button.painter-button.primary", {
id: `toggle-mask-btn-${node.id}`,
textContent: "Show Mask",
textContent: "M", // Fallback text until icon loads
title: "Toggle mask overlay visibility",
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '32px',
maxWidth: '32px',
padding: '4px',
fontSize: '12px',
fontWeight: 'bold'
},
onclick: (e) => {
const button = e.target;
const button = e.currentTarget;
canvas.maskTool.toggleOverlayVisibility();
canvas.render();
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
button.textContent = "Show Mask";
}
else {
button.classList.remove('primary');
button.textContent = "Hide Mask";
const iconContainer = button.querySelector('.mask-icon-container');
if (iconContainer) {
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
iconContainer.style.opacity = '1';
}
else {
button.classList.remove('primary');
iconContainer.style.opacity = '0.5';
}
}
}
}),
@@ -503,6 +517,47 @@ async function createCanvasWidget(node, widget, app) {
]),
$el("div.painter-separator")
]);
// Function to create mask icon
const createMaskIcon = () => {
const iconContainer = document.createElement('div');
iconContainer.className = 'mask-icon-container';
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
}
else {
// Fallback text
iconContainer.textContent = 'M';
iconContainer.style.fontSize = '12px';
iconContainer.style.color = '#ffffff';
}
return iconContainer;
};
const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0;
@@ -531,10 +586,39 @@ async function createCanvasWidget(node, widget, app) {
};
updateButtonStates();
canvas.updateHistoryButtons();
// Add mask icon to toggle mask button after icons are loaded
setTimeout(async () => {
try {
await iconLoader.preloadToolIcons();
const toggleMaskBtn = controlPanel.querySelector(`#toggle-mask-btn-${node.id}`);
if (toggleMaskBtn && !toggleMaskBtn.querySelector('.mask-icon-container')) {
// Clear fallback text
toggleMaskBtn.textContent = '';
const maskIcon = createMaskIcon();
toggleMaskBtn.appendChild(maskIcon);
// Set initial state based on mask visibility
if (canvas.maskTool.isOverlayVisible) {
toggleMaskBtn.classList.add('primary');
maskIcon.style.opacity = '1';
}
else {
toggleMaskBtn.classList.remove('primary');
maskIcon.style.opacity = '0.5';
}
}
}
catch (error) {
log.warn('Failed to load mask icon:', error);
}
}, 200);
// Debounce timer for updateOutput to prevent excessive updates
let updateOutputTimer = null;
const updateOutput = async (node, canvas) => {
// Check if preview is disabled - if so, skip updateOutput entirely
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
if (triggerWidget) {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
}
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
if (showPreviewWidget && !showPreviewWidget.value) {
log.debug("Preview disabled, skipping updateOutput");
@@ -544,10 +628,6 @@ async function createCanvasWidget(node, widget, app) {
node.imgs = [placeholder];
return;
}
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
if (triggerWidget) {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
}
// Clear previous timer
if (updateOutputTimer) {
clearTimeout(updateOutputTimer);

178
js/utils/IconLoader.js Normal file
View File

@@ -0,0 +1,178 @@
import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('IconLoader');
// Define tool constants for LayerForge
export const LAYERFORGE_TOOLS = {
VISIBILITY: 'visibility',
MOVE: 'move',
ROTATE: 'rotate',
SCALE: 'scale',
DELETE: 'delete',
DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode',
OPACITY: 'opacity',
MASK: 'mask',
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings'
};
// SVG Icons for LayerForge tools
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
};
// Tool colors for LayerForge
const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
};
export class IconLoader {
constructor() {
this._iconCache = {};
this._loadingPromises = new Map();
log.info('IconLoader initialized');
}
/**
* Preload all LayerForge tool icons
*/
preloadToolIcons() {
log.info('Starting to preload LayerForge tool icons');
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
return this.loadIcon(tool);
});
return Promise.all(loadPromises).then(() => {
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
}).catch(error => {
log.error('Error preloading tool icons:', error);
});
}
/**
* Load a specific icon by tool name
*/
async loadIcon(tool) {
// Check if already cached
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
return this._iconCache[tool];
}
// Check if already loading
if (this._loadingPromises.has(tool)) {
return this._loadingPromises.get(tool);
}
// Create fallback canvas first
const fallbackCanvas = this.createFallbackIcon(tool);
this._iconCache[tool] = fallbackCanvas;
// Start loading the SVG icon
const loadPromise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this._iconCache[tool] = img;
this._loadingPromises.delete(tool);
log.debug(`Successfully loaded icon for tool: ${tool}`);
resolve(img);
};
img.onerror = (error) => {
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
this._loadingPromises.delete(tool);
// Keep the fallback canvas in cache
reject(error);
};
const iconData = LAYERFORGE_TOOL_ICONS[tool];
if (iconData) {
img.src = iconData;
}
else {
log.warn(`No icon data found for tool: ${tool}`);
reject(new Error(`No icon data for tool: ${tool}`));
}
});
this._loadingPromises.set(tool, loadPromise);
return loadPromise;
}
/**
* Create a fallback canvas icon with colored background and text
*/
createFallbackIcon(tool) {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Failed to get canvas context for fallback icon');
return canvas;
}
// Fill background with tool color
const color = LAYERFORGE_TOOL_COLORS[tool] || '#888888';
ctx.fillStyle = color;
ctx.fillRect(0, 0, 24, 24);
// Add border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 23, 23);
// Add text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const firstChar = tool.charAt(0).toUpperCase();
ctx.fillText(firstChar, 12, 12);
return canvas;
}
/**
* Get cached icon (canvas or image)
*/
getIcon(tool) {
return this._iconCache[tool] || null;
}
/**
* Check if icon is loaded (as image, not fallback canvas)
*/
isIconLoaded(tool) {
return this._iconCache[tool] instanceof HTMLImageElement;
}
/**
* Clear all cached icons
*/
clearCache() {
this._iconCache = {};
this._loadingPromises.clear();
log.info('Icon cache cleared');
}
/**
* Get all available tool names
*/
getAvailableTools() {
return Object.values(LAYERFORGE_TOOLS);
}
/**
* Get tool color
*/
getToolColor(tool) {
return LAYERFORGE_TOOL_COLORS[tool] || '#888888';
}
}
// Export singleton instance
export const iconLoader = new IconLoader();
// Export for external use
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };

View File

@@ -169,7 +169,10 @@ export class BatchPreviewManager {
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.remove('primary');
toggleBtn.textContent = "Hide Mask";
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
if (iconContainer) {
iconContainer.style.opacity = '0.5';
}
}
this.canvas.render();
}
@@ -191,6 +194,11 @@ export class BatchPreviewManager {
this.worldY += paddingInWorld;
}
// Hide all batch layers initially, then show only the first one
this.layers.forEach((layer: Layer) => {
layer.visible = false;
});
this._update();
}
@@ -213,12 +221,24 @@ export class BatchPreviewManager {
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
if (toggleBtn) {
toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask";
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
if (iconContainer) {
iconContainer.style.opacity = '1';
}
}
}
this.maskWasVisible = false;
this.canvas.layers.forEach((l: Layer) => (l as any).visible = true);
// Only make visible the layers that were part of the batch preview
this.layers.forEach((layer: Layer) => {
layer.visible = true;
});
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
}
@@ -264,14 +284,23 @@ export class BatchPreviewManager {
private _focusOnLayer(layer: Layer): void {
if (!layer) return;
log.debug(`Focusing on layer ${layer.id}`);
log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
// Move the selected layer to the top of the layer stack
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
// Hide all batch layers first
this.layers.forEach((l: Layer) => {
l.visible = false;
});
// Show only the current layer
layer.visible = true;
this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
}
}

View File

@@ -413,6 +413,29 @@ export class Canvas {
this.maskTool.updatePosition(-finalX, -finalY);
// Update batch preview managers like in canvas resize/move operations
if (this.pendingBatchContext) {
this.pendingBatchContext.outputArea.x -= finalX;
this.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.pendingBatchContext.spawnPosition.x -= finalX;
this.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during shape definition:", this.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.batchPreviewManagers && this.batchPreviewManagers.length > 0) {
this.batchPreviewManagers.forEach((manager: any) => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.viewport.x -= finalX;
this.viewport.y -= finalY;

View File

@@ -546,7 +546,8 @@ export class CanvasIO {
originalWidth: image.width,
originalHeight: image.height,
blendMode: 'normal',
opacity: 1
opacity: 1,
visible: true
};
this.canvas.layers.push(layer);
@@ -729,6 +730,7 @@ export class CanvasIO {
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
visible: true,
};
this.canvas.layers.push(layer);

View File

@@ -128,12 +128,17 @@ export class CanvasLayers {
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
this.internalClipboard.forEach((clipboardLayer: Layer) => {
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
this.internalClipboard.forEach((clipboardLayer: Layer, index: number) => {
const newLayer: Layer = {
...clipboardLayer,
x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + offsetY,
zIndex: this.canvas.layers.length
zIndex: maxZIndex + 1 + index // Ensure pasted layers maintain their relative order
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
@@ -189,6 +194,11 @@ export class CanvasLayers {
finalY = area.y + (area.height - finalHeight) / 2;
}
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
const layer: Layer = {
id: generateUUID(),
image: image,
@@ -201,9 +211,10 @@ export class CanvasLayers {
originalWidth: image.width,
originalHeight: image.height,
rotation: 0,
zIndex: this.canvas.layers.length,
zIndex: maxZIndex + 1, // Always add new layer on top
blendMode: 'normal',
opacity: 1,
visible: true,
...layerProps
};
@@ -360,6 +371,9 @@ export class CanvasLayers {
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
// Skip invisible layers
if (!layer.visible) continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
@@ -414,7 +428,11 @@ export class CanvasLayers {
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
sortedLayers.forEach(layer => {
if (layer.visible) {
this._drawLayer(ctx, layer, options);
}
});
}
public drawLayersToContext(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
@@ -567,8 +585,52 @@ export class CanvasLayers {
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
`;
titleBar.textContent = 'Blend Mode';
const titleText = document.createElement('span');
titleText.textContent = 'Blend Mode';
titleText.style.cssText = `
flex: 1;
cursor: move;
`;
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#4a4a4a';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = 'transparent';
};
closeButton.onclick = (e: MouseEvent) => {
e.stopPropagation();
this.closeBlendModeMenu();
};
titleBar.appendChild(titleText);
titleBar.appendChild(closeButton);
const content = document.createElement('div');
content.style.cssText = `padding: 5px;`;
@@ -963,7 +1025,8 @@ export class CanvasLayers {
rotation: 0,
zIndex: minZIndex,
blendMode: 'normal',
opacity: 1
opacity: 1,
visible: true
};
this.canvas.layers = this.canvas.layers.filter((layer: Layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));

View File

@@ -1,4 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import type { Canvas } from './Canvas';
import type { Layer } from './types';
@@ -28,9 +29,109 @@ export class CanvasLayersPanel {
this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this);
// Preload icons
this.initializeIcons();
log.info('CanvasLayersPanel initialized');
}
private async initializeIcons(): Promise<void> {
try {
await iconLoader.preloadToolIcons();
log.debug('Icons preloaded successfully');
} catch (error) {
log.warn('Failed to preload icons, using fallbacks:', error);
}
}
private createIconElement(toolName: string, size: number = 16): HTMLElement {
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: ${size}px;
height: ${size}px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(icon, 0, 0, size, size);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback text
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
}
return iconContainer;
}
private createVisibilityIcon(isVisible: boolean): HTMLElement {
if (isVisible) {
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
} else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.globalAlpha = 0.3;
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
}
return iconContainer;
}
}
createPanelStructure(): HTMLElement {
this.container = document.createElement('div');
this.container.className = 'layers-panel';
@@ -39,7 +140,7 @@ export class CanvasLayersPanel {
<div class="layers-panel-header">
<span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
</div>
</div>
<div class="layers-container" id="layers-container">
@@ -253,6 +354,23 @@ export class CanvasLayersPanel {
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
document.head.appendChild(style);
@@ -263,6 +381,12 @@ export class CanvasLayersPanel {
if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
// Add delete icon to button
if (deleteBtn) {
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
deleteBtn.appendChild(deleteIcon);
}
deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
@@ -313,10 +437,18 @@ export class CanvasLayersPanel {
}
layerRow.innerHTML = `
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
<div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`;
// Add visibility icon
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
if (visibilityToggle) {
const visibilityIcon = this.createVisibilityIcon(layer.visible);
visibilityToggle.appendChild(visibilityIcon);
}
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail');
if (thumbnailContainer) {
this.generateThumbnail(layer, thumbnailContainer);
@@ -372,6 +504,16 @@ export class CanvasLayersPanel {
}
});
// Add visibility toggle event listener
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
if (visibilityToggle) {
visibilityToggle.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.toggleLayerVisibility(layer);
});
}
layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
@@ -464,6 +606,24 @@ export class CanvasLayersPanel {
return uniqueName;
}
toggleLayerVisibility(layer: Layer): void {
layer.visible = !layer.visible;
// If layer became invisible and is selected, deselect it
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection);
}
this.canvas.render();
this.canvas.requestSaveState();
// Update the eye icon in the panel
this.renderLayers();
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
}
deleteSelectedLayers(): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion');

View File

@@ -60,7 +60,7 @@ export class CanvasRenderer {
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
if (!layer.image || !layer.visible) return;
ctx.save();
const currentTransform = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -205,7 +205,7 @@ export class CanvasRenderer {
renderLayerInfo(ctx: any) {
if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.selectedLayers.forEach((layer: any) => {
if (!layer.image) return;
if (!layer.image || !layer.visible) return;
const layerIndex = this.canvas.layers.indexOf(layer);
const currentWidth = Math.round(layer.width);

View File

@@ -52,7 +52,8 @@ export class CanvasSelection {
*/
updateSelection(newSelection: any) {
const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || [];
// Filter out invisible layers from selection
this.selectedLayers = (newSelection || []).filter((layer: any) => layer.visible !== false);
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli

View File

@@ -14,6 +14,7 @@ import {clearAllCanvasStates} from "./db.js";
import {ImageCache} from "./ImageCache.js";
import {generateUniqueFileName} from "./utils/CommonUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { registerImageInClipspace, startSAMDetectorMonitoring, setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
import type { ComfyNode, Layer, AddMode } from './types';
@@ -402,19 +403,32 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button.primary", {
id: `toggle-mask-btn-${node.id}`,
textContent: "Show Mask",
textContent: "M", // Fallback text until icon loads
title: "Toggle mask overlay visibility",
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '32px',
maxWidth: '32px',
padding: '4px',
fontSize: '12px',
fontWeight: 'bold'
},
onclick: (e: MouseEvent) => {
const button = e.target as HTMLButtonElement;
const button = e.currentTarget as HTMLButtonElement;
canvas.maskTool.toggleOverlayVisibility();
canvas.render();
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
button.textContent = "Show Mask";
} else {
button.classList.remove('primary');
button.textContent = "Hide Mask";
const iconContainer = button.querySelector('.mask-icon-container') as HTMLElement;
if (iconContainer) {
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
iconContainer.style.opacity = '1';
} else {
button.classList.remove('primary');
iconContainer.style.opacity = '0.5';
}
}
}
}),
@@ -539,6 +553,48 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
]);
// Function to create mask icon
const createMaskIcon = (): HTMLElement => {
const iconContainer = document.createElement('div');
iconContainer.className = 'mask-icon-container';
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback text
iconContainer.textContent = 'M';
iconContainer.style.fontSize = '12px';
iconContainer.style.color = '#ffffff';
}
return iconContainer;
};
const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0;
@@ -569,11 +625,44 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
updateButtonStates();
canvas.updateHistoryButtons();
// Add mask icon to toggle mask button after icons are loaded
setTimeout(async () => {
try {
await iconLoader.preloadToolIcons();
const toggleMaskBtn = controlPanel.querySelector(`#toggle-mask-btn-${node.id}`) as HTMLButtonElement;
if (toggleMaskBtn && !toggleMaskBtn.querySelector('.mask-icon-container')) {
// Clear fallback text
toggleMaskBtn.textContent = '';
const maskIcon = createMaskIcon();
toggleMaskBtn.appendChild(maskIcon);
// Set initial state based on mask visibility
if (canvas.maskTool.isOverlayVisible) {
toggleMaskBtn.classList.add('primary');
maskIcon.style.opacity = '1';
} else {
toggleMaskBtn.classList.remove('primary');
maskIcon.style.opacity = '0.5';
}
}
} catch (error) {
log.warn('Failed to load mask icon:', error);
}
}, 200);
// Debounce timer for updateOutput to prevent excessive updates
let updateOutputTimer: NodeJS.Timeout | null = null;
const updateOutput = async (node: ComfyNode, canvas: Canvas) => {
// Check if preview is disabled - if so, skip updateOutput entirely
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
if (triggerWidget) {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
}
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
if (showPreviewWidget && !showPreviewWidget.value) {
log.debug("Preview disabled, skipping updateOutput");
@@ -584,11 +673,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
return;
}
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
if (triggerWidget) {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
}
// Clear previous timer
if (updateOutputTimer) {
clearTimeout(updateOutputTimer);

View File

@@ -16,6 +16,7 @@ export interface Layer {
zIndex: number;
blendMode: string;
opacity: number;
visible: boolean;
mask?: Float32Array;
flipH?: boolean;
flipV?: boolean;

224
src/utils/IconLoader.ts Normal file
View File

@@ -0,0 +1,224 @@
import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('IconLoader');
// Define tool constants for LayerForge
export const LAYERFORGE_TOOLS = {
VISIBILITY: 'visibility',
MOVE: 'move',
ROTATE: 'rotate',
SCALE: 'scale',
DELETE: 'delete',
DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode',
OPACITY: 'opacity',
MASK: 'mask',
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings'
} as const;
// SVG Icons for LayerForge tools
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
};
// Tool colors for LayerForge
const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
};
export interface IconCache {
[key: string]: HTMLCanvasElement | HTMLImageElement;
}
export class IconLoader {
private _iconCache: IconCache = {};
private _loadingPromises: Map<string, Promise<HTMLImageElement>> = new Map();
constructor() {
log.info('IconLoader initialized');
}
/**
* Preload all LayerForge tool icons
*/
preloadToolIcons(): Promise<void> {
log.info('Starting to preload LayerForge tool icons');
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
return this.loadIcon(tool);
});
return Promise.all(loadPromises).then(() => {
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
}).catch(error => {
log.error('Error preloading tool icons:', error);
});
}
/**
* Load a specific icon by tool name
*/
async loadIcon(tool: string): Promise<HTMLImageElement> {
// Check if already cached
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
return this._iconCache[tool] as HTMLImageElement;
}
// Check if already loading
if (this._loadingPromises.has(tool)) {
return this._loadingPromises.get(tool)!;
}
// Create fallback canvas first
const fallbackCanvas = this.createFallbackIcon(tool);
this._iconCache[tool] = fallbackCanvas;
// Start loading the SVG icon
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => {
this._iconCache[tool] = img;
this._loadingPromises.delete(tool);
log.debug(`Successfully loaded icon for tool: ${tool}`);
resolve(img);
};
img.onerror = (error) => {
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
this._loadingPromises.delete(tool);
// Keep the fallback canvas in cache
reject(error);
};
const iconData = LAYERFORGE_TOOL_ICONS[tool as keyof typeof LAYERFORGE_TOOL_ICONS];
if (iconData) {
img.src = iconData;
} else {
log.warn(`No icon data found for tool: ${tool}`);
reject(new Error(`No icon data for tool: ${tool}`));
}
});
this._loadingPromises.set(tool, loadPromise);
return loadPromise;
}
/**
* Create a fallback canvas icon with colored background and text
*/
private createFallbackIcon(tool: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Failed to get canvas context for fallback icon');
return canvas;
}
// Fill background with tool color
const color = LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
ctx.fillStyle = color;
ctx.fillRect(0, 0, 24, 24);
// Add border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 23, 23);
// Add text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const firstChar = tool.charAt(0).toUpperCase();
ctx.fillText(firstChar, 12, 12);
return canvas;
}
/**
* Get cached icon (canvas or image)
*/
getIcon(tool: string): HTMLCanvasElement | HTMLImageElement | null {
return this._iconCache[tool] || null;
}
/**
* Check if icon is loaded (as image, not fallback canvas)
*/
isIconLoaded(tool: string): boolean {
return this._iconCache[tool] instanceof HTMLImageElement;
}
/**
* Clear all cached icons
*/
clearCache(): void {
this._iconCache = {};
this._loadingPromises.clear();
log.info('Icon cache cleared');
}
/**
* Get all available tool names
*/
getAvailableTools(): string[] {
return Object.values(LAYERFORGE_TOOLS);
}
/**
* Get tool color
*/
getToolColor(tool: string): string {
return LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
}
}
// Export singleton instance
export const iconLoader = new IconLoader();
// Export for external use
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };