From 3b1a69041c6f083c476d3ea6811c0efc9109375f Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 24 Jul 2025 19:10:17 +0200 Subject: [PATCH] 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. --- js/BatchPreviewManager.js | 38 +++++-- js/Canvas.js | 20 ++++ js/CanvasIO.js | 4 +- js/CanvasLayers.js | 67 ++++++++++- js/CanvasLayersPanel.js | 152 ++++++++++++++++++++++++- js/CanvasRenderer.js | 4 +- js/CanvasSelection.js | 3 +- js/CanvasView.js | 106 +++++++++++++++--- js/utils/IconLoader.js | 178 +++++++++++++++++++++++++++++ src/BatchPreviewManager.ts | 43 +++++-- src/Canvas.ts | 23 ++++ src/CanvasIO.ts | 4 +- src/CanvasLayers.ts | 75 ++++++++++++- src/CanvasLayersPanel.ts | 162 ++++++++++++++++++++++++++- src/CanvasRenderer.ts | 4 +- src/CanvasSelection.ts | 3 +- src/CanvasView.ts | 110 +++++++++++++++--- src/types.ts | 1 + src/utils/IconLoader.ts | 224 +++++++++++++++++++++++++++++++++++++ 19 files changed, 1159 insertions(+), 62 deletions(-) create mode 100644 js/utils/IconLoader.js create mode 100644 src/utils/IconLoader.ts diff --git a/js/BatchPreviewManager.js b/js/BatchPreviewManager.js index 9a3ceee..c84446f 100644 --- a/js/BatchPreviewManager.js +++ b/js/BatchPreviewManager.js @@ -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(); } } diff --git a/js/Canvas.js b/js/Canvas.js index 9a536bd..1f6f2de 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -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(); diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 605d113..fd1f4f8 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -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]); diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 0665d31..5381137 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -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); diff --git a/js/CanvasLayersPanel.js b/js/CanvasLayersPanel.js index 08c7fe2..c8128e3 100644 --- a/js/CanvasLayersPanel.js +++ b/js/CanvasLayersPanel.js @@ -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 {
Layers
- +
@@ -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 = ` +
${layer.name} `; + // 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'); diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 0930373..451267a 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -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); diff --git a/js/CanvasSelection.js b/js/CanvasSelection.js index f691ef5..0836435 100644 --- a/js/CanvasSelection.js +++ b/js/CanvasSelection.js @@ -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 || diff --git a/js/CanvasView.js b/js/CanvasView.js index ff5bf70..019405b 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -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); diff --git a/js/utils/IconLoader.js b/js/utils/IconLoader.js new file mode 100644 index 0000000..f572702 --- /dev/null +++ b/js/utils/IconLoader.js @@ -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('')}`, + [LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + [LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}` +}; +// 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 }; diff --git a/src/BatchPreviewManager.ts b/src/BatchPreviewManager.ts index e1ff0d8..1236726 100644 --- a/src/BatchPreviewManager.ts +++ b/src/BatchPreviewManager.ts @@ -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(); } } diff --git a/src/Canvas.ts b/src/Canvas.ts index 576d69a..67068ee 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -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; diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 1fb2f2f..a0b2342 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -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); diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index 2d7ecb0..eb34482 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -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)); diff --git a/src/CanvasLayersPanel.ts b/src/CanvasLayersPanel.ts index 464bdb5..d52a18c 100644 --- a/src/CanvasLayersPanel.ts +++ b/src/CanvasLayersPanel.ts @@ -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 { + 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 {
Layers
- +
@@ -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 = ` +
${layer.name} `; + // 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); @@ -372,6 +504,16 @@ export class CanvasLayersPanel { } }); + // Add visibility toggle event listener + const visibilityToggle = layerRow.querySelector('.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'); diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index 8e51a42..d053542 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -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); diff --git a/src/CanvasSelection.ts b/src/CanvasSelection.ts index c0151f1..a56ccae 100644 --- a/src/CanvasSelection.ts +++ b/src/CanvasSelection.ts @@ -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 diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 6cf9d08..daaeae1 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -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); diff --git a/src/types.ts b/src/types.ts index 3be21dd..cac196e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,7 @@ export interface Layer { zIndex: number; blendMode: string; opacity: number; + visible: boolean; mask?: Float32Array; flipH?: boolean; flipV?: boolean; diff --git a/src/utils/IconLoader.ts b/src/utils/IconLoader.ts new file mode 100644 index 0000000..7eb82cb --- /dev/null +++ b/src/utils/IconLoader.ts @@ -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('')}`, + + [LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`, + + [LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}` +}; + +// 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> = new Map(); + + constructor() { + log.info('IconLoader initialized'); + } + + /** + * Preload all LayerForge tool icons + */ + preloadToolIcons(): Promise { + 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 { + // 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((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 };