import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('CustomShapeMenu'); export class CustomShapeMenu { constructor(canvas) { this.canvas = canvas; this.element = null; this.worldX = 0; this.worldY = 0; this.uiInitialized = false; this.tooltip = null; } show() { if (!this.canvas.outputAreaShape) { return; } this._createUI(); if (this.element) { this.element.style.display = 'block'; } // Position in top-left corner of viewport (closer to edge) const viewLeft = this.canvas.viewport.x; const viewTop = this.canvas.viewport.y; this.worldX = viewLeft + (8 / this.canvas.viewport.zoom); this.worldY = viewTop + (8 / this.canvas.viewport.zoom); this.updateScreenPosition(); } hide() { if (this.element) { this.element.remove(); this.element = null; this.uiInitialized = false; } this.hideTooltip(); } updateScreenPosition() { if (!this.element) return; const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; this.element.style.transform = `translate(${screenX}px, ${screenY}px)`; } _createUI() { if (this.uiInitialized) return; this.element = document.createElement('div'); this.element.id = 'layerforge-custom-shape-menu'; this.element.style.cssText = ` position: absolute; top: 0; left: 0; background-color: #333; color: white; padding: 8px 15px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); display: none; flex-direction: column; gap: 4px; font-family: sans-serif; font-size: 12px; z-index: 1001; border: 1px solid #555; user-select: none; min-width: 200px; `; // Create menu content const lines = [ "🎯 Custom Output Area Active" ]; lines.forEach(line => { const lineElement = document.createElement('div'); lineElement.textContent = line; lineElement.style.cssText = ` margin: 2px 0; line-height: 18px; `; this.element.appendChild(lineElement); }); // Create a container for the entire shape mask feature set const featureContainer = document.createElement('div'); featureContainer.id = 'shape-mask-feature-container'; featureContainer.style.cssText = ` background-color: #282828; border-radius: 6px; margin-top: 6px; padding: 4px 0; border: 1px solid #444; `; // Add main auto-apply checkbox to the new container const checkboxContainer = this._createCheckbox(() => `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`, () => { this.canvas.autoApplyShapeMask = !this.canvas.autoApplyShapeMask; if (this.canvas.autoApplyShapeMask) { this.canvas.maskTool.applyShapeMask(); log.info("Auto-apply shape mask enabled - mask applied automatically"); } else { this.canvas.maskTool.removeShapeMask(); this.canvas.shapeMaskExpansion = false; this.canvas.shapeMaskFeather = false; log.info("Auto-apply shape mask disabled - mask area removed and sub-options reset."); } this._updateUI(); this.canvas.render(); }, "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary."); featureContainer.appendChild(checkboxContainer); // Add expansion checkbox const expansionContainer = this._createCheckbox(() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`, () => { this.canvas.shapeMaskExpansion = !this.canvas.shapeMaskExpansion; this._updateUI(); if (this.canvas.autoApplyShapeMask) { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } }, "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward."); expansionContainer.id = 'expansion-checkbox'; featureContainer.appendChild(expansionContainer); // Add expansion slider container const expansionSliderContainer = document.createElement('div'); expansionSliderContainer.id = 'expansion-slider-container'; expansionSliderContainer.style.cssText = ` margin: 0 8px 6px 8px; padding: 4px 8px; display: none; `; const expansionSliderLabel = document.createElement('div'); expansionSliderLabel.textContent = 'Expansion amount:'; expansionSliderLabel.style.cssText = ` font-size: 11px; margin-bottom: 4px; color: #ccc; `; const expansionSlider = document.createElement('input'); expansionSlider.type = 'range'; expansionSlider.min = '-300'; expansionSlider.max = '300'; expansionSlider.value = String(this.canvas.shapeMaskExpansionValue); expansionSlider.style.cssText = ` width: 100%; height: 4px; background: #555; outline: none; border-radius: 2px; `; const expansionValueDisplay = document.createElement('div'); expansionValueDisplay.style.cssText = ` font-size: 10px; text-align: center; margin-top: 2px; color: #aaa; `; const updateExpansionSliderDisplay = () => { const value = parseInt(expansionSlider.value); this.canvas.shapeMaskExpansionValue = value; expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`; }; let isExpansionDragging = false; expansionSlider.onmousedown = () => { isExpansionDragging = true; }; expansionSlider.oninput = () => { updateExpansionSliderDisplay(); if (this.canvas.autoApplyShapeMask) { if (isExpansionDragging) { const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0; this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue); } else { this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(false); this.canvas.render(); } } }; expansionSlider.onmouseup = () => { isExpansionDragging = false; if (this.canvas.autoApplyShapeMask) { this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(true); this.canvas.render(); } }; updateExpansionSliderDisplay(); expansionSliderContainer.appendChild(expansionSliderLabel); expansionSliderContainer.appendChild(expansionSlider); expansionSliderContainer.appendChild(expansionValueDisplay); featureContainer.appendChild(expansionSliderContainer); // Add feather checkbox const featherContainer = this._createCheckbox(() => `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`, () => { this.canvas.shapeMaskFeather = !this.canvas.shapeMaskFeather; this._updateUI(); if (this.canvas.autoApplyShapeMask) { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } }, "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent."); featherContainer.id = 'feather-checkbox'; featureContainer.appendChild(featherContainer); // Add feather slider container const featherSliderContainer = document.createElement('div'); featherSliderContainer.id = 'feather-slider-container'; featherSliderContainer.style.cssText = ` margin: 0 8px 6px 8px; padding: 4px 8px; display: none; `; const featherSliderLabel = document.createElement('div'); featherSliderLabel.textContent = 'Feather amount:'; featherSliderLabel.style.cssText = ` font-size: 11px; margin-bottom: 4px; color: #ccc; `; const featherSlider = document.createElement('input'); featherSlider.type = 'range'; featherSlider.min = '0'; featherSlider.max = '300'; featherSlider.value = String(this.canvas.shapeMaskFeatherValue); featherSlider.style.cssText = ` width: 100%; height: 4px; background: #555; outline: none; border-radius: 2px; `; const featherValueDisplay = document.createElement('div'); featherValueDisplay.style.cssText = ` font-size: 10px; text-align: center; margin-top: 2px; color: #aaa; `; const updateFeatherSliderDisplay = () => { const value = parseInt(featherSlider.value); this.canvas.shapeMaskFeatherValue = value; featherValueDisplay.textContent = `${value}px`; }; let isFeatherDragging = false; featherSlider.onmousedown = () => { isFeatherDragging = true; }; featherSlider.oninput = () => { updateFeatherSliderDisplay(); if (this.canvas.autoApplyShapeMask) { if (isFeatherDragging) { const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0; this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue); } else { this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(false); this.canvas.render(); } } }; featherSlider.onmouseup = () => { isFeatherDragging = false; if (this.canvas.autoApplyShapeMask) { this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(true); // true = save state this.canvas.render(); } }; updateFeatherSliderDisplay(); featherSliderContainer.appendChild(featherSliderLabel); featherSliderContainer.appendChild(featherSlider); featherSliderContainer.appendChild(featherValueDisplay); featureContainer.appendChild(featherSliderContainer); this.element.appendChild(featureContainer); // Create output area extension container const extensionContainer = document.createElement('div'); extensionContainer.id = 'output-area-extension-container'; extensionContainer.style.cssText = ` background-color: #282828; border-radius: 6px; margin-top: 6px; padding: 4px 0; border: 1px solid #444; `; // Add main extension checkbox const extensionCheckboxContainer = this._createCheckbox(() => `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`, () => { this.canvas.outputAreaExtensionEnabled = !this.canvas.outputAreaExtensionEnabled; if (this.canvas.outputAreaExtensionEnabled) { // When enabling, capture current canvas size as the baseline this.canvas.originalCanvasSize = { width: this.canvas.width, height: this.canvas.height }; log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`); } else { // Reset all extensions when disabled this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; } this._updateExtensionUI(); this._updateCanvasSize(); // Update canvas size when toggling this.canvas.render(); log.info(`Output area extension ${this.canvas.outputAreaExtensionEnabled ? 'enabled' : 'disabled'}`); }, "Allows extending the output area boundaries in all directions without changing the custom shape."); extensionContainer.appendChild(extensionCheckboxContainer); // Create sliders container const slidersContainer = document.createElement('div'); slidersContainer.id = 'extension-sliders-container'; slidersContainer.style.cssText = ` margin: 0 8px 6px 8px; padding: 4px 8px; display: none; `; // Helper function to create a slider with preview system const createExtensionSlider = (label, direction) => { const sliderContainer = document.createElement('div'); sliderContainer.style.cssText = ` margin: 6px 0; `; const sliderLabel = document.createElement('div'); sliderLabel.textContent = label; sliderLabel.style.cssText = ` font-size: 11px; margin-bottom: 4px; color: #ccc; `; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '500'; slider.value = String(this.canvas.outputAreaExtensions[direction]); slider.style.cssText = ` width: 100%; height: 4px; background: #555; outline: none; border-radius: 2px; `; const valueDisplay = document.createElement('div'); valueDisplay.style.cssText = ` font-size: 10px; text-align: center; margin-top: 2px; color: #aaa; `; const updateDisplay = () => { const value = parseInt(slider.value); valueDisplay.textContent = `${value}px`; }; let isDragging = false; slider.onmousedown = () => { isDragging = true; }; slider.oninput = () => { updateDisplay(); if (isDragging) { // During dragging, show preview const previewExtensions = { ...this.canvas.outputAreaExtensions }; previewExtensions[direction] = parseInt(slider.value); this.canvas.outputAreaExtensionPreview = previewExtensions; this.canvas.render(); } else { // Not dragging, apply immediately (for keyboard navigation) this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); this._updateCanvasSize(); this.canvas.render(); } }; slider.onmouseup = () => { if (isDragging) { isDragging = false; // Apply the final value and clear preview this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); this.canvas.outputAreaExtensionPreview = null; this._updateCanvasSize(); this.canvas.render(); } }; // Handle mouse leave (in case user drags outside) slider.onmouseleave = () => { if (isDragging) { isDragging = false; // Apply the final value and clear preview this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); this.canvas.outputAreaExtensionPreview = null; this._updateCanvasSize(); this.canvas.render(); } }; updateDisplay(); sliderContainer.appendChild(sliderLabel); sliderContainer.appendChild(slider); sliderContainer.appendChild(valueDisplay); return sliderContainer; }; // Add all four sliders slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top')); slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom')); slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left')); slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right')); extensionContainer.appendChild(slidersContainer); this.element.appendChild(extensionContainer); // Add to DOM if (this.canvas.canvas.parentElement) { this.canvas.canvas.parentElement.appendChild(this.element); } else { log.error("Could not find parent node to attach custom shape menu."); } this.uiInitialized = true; this._updateUI(); // Add viewport change listener to update shape preview when zooming/panning this._addViewportChangeListener(); } _createCheckbox(textFn, clickHandler, tooltipText) { const container = document.createElement('div'); container.style.cssText = ` margin: 6px 0 2px 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; transition: background-color 0.2s; line-height: 18px; `; container.onmouseover = () => { container.style.backgroundColor = '#555'; }; container.onmouseout = () => { container.style.backgroundColor = 'transparent'; }; const updateText = () => { container.textContent = textFn(); }; updateText(); container.onclick = (e) => { e.preventDefault(); e.stopPropagation(); clickHandler(); updateText(); }; // Add tooltip if provided if (tooltipText) { this._addTooltip(container, tooltipText); } return container; } _updateUI() { if (!this.element) return; // Toggle visibility of sub-options based on the main checkbox state const expansionCheckbox = this.element.querySelector('#expansion-checkbox'); if (expansionCheckbox) { expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none'; } const featherCheckbox = this.element.querySelector('#feather-checkbox'); if (featherCheckbox) { featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none'; } // Update sliders visibility based on their respective checkboxes const expansionSliderContainer = this.element.querySelector('#expansion-slider-container'); if (expansionSliderContainer) { expansionSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none'; } const featherSliderContainer = this.element.querySelector('#feather-slider-container'); if (featherSliderContainer) { featherSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none'; } // Update checkbox texts const checkboxes = this.element.querySelectorAll('div[style*="cursor: pointer"]'); checkboxes.forEach((checkbox, index) => { if (index === 0) { // Main checkbox checkbox.textContent = `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`; } else if (index === 1) { // Expansion checkbox checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Dilate/Erode mask`; } else if (index === 2) { // Feather checkbox checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`; } else if (index === 3) { // Extension checkbox checkbox.textContent = `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`; } }); } _updateExtensionUI() { if (!this.element) return; // Toggle visibility of extension sliders based on the extension checkbox state const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container'); if (extensionSlidersContainer) { extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none'; } // Update slider values if they exist if (this.canvas.outputAreaExtensionEnabled) { const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]'); const directions = ['top', 'bottom', 'left', 'right']; sliders?.forEach((slider, index) => { const direction = directions[index]; if (direction) { slider.value = String(this.canvas.outputAreaExtensions[direction]); // Update the corresponding value display const valueDisplay = slider.parentElement?.querySelector('div:last-child'); if (valueDisplay) { valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`; } } }); } } /** * Add viewport change listener to update shape preview when zooming/panning */ _addViewportChangeListener() { // Store previous viewport state to detect changes let previousViewport = { x: this.canvas.viewport.x, y: this.canvas.viewport.y, zoom: this.canvas.viewport.zoom }; // Check for viewport changes in render loop const checkViewportChange = () => { if (this.canvas.maskTool.shapePreviewVisible) { const current = this.canvas.viewport; // Check if viewport has changed if (current.x !== previousViewport.x || current.y !== previousViewport.y || current.zoom !== previousViewport.zoom) { // Update shape preview with current expansion/feather values const expansionValue = this.canvas.shapeMaskExpansionValue || 0; const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0; this.canvas.maskTool.showShapePreview(expansionValue, featherValue); // Update previous viewport state previousViewport = { x: current.x, y: current.y, zoom: current.zoom }; } } // Continue checking if UI is still active if (this.uiInitialized) { requestAnimationFrame(checkViewportChange); } }; // Start the viewport change detection requestAnimationFrame(checkViewportChange); } _addTooltip(element, text) { element.addEventListener('mouseenter', (e) => { this.showTooltip(text, e); }); element.addEventListener('mouseleave', () => { this.hideTooltip(); }); element.addEventListener('mousemove', (e) => { if (this.tooltip && this.tooltip.style.display === 'block') { this.updateTooltipPosition(e); } }); } showTooltip(text, event) { this.hideTooltip(); // Hide any existing tooltip this.tooltip = document.createElement('div'); this.tooltip.textContent = text; this.tooltip.style.cssText = ` position: fixed; background-color: #1a1a1a; color: #ffffff; padding: 8px 12px; border-radius: 6px; font-size: 12px; font-family: sans-serif; line-height: 1.4; max-width: 250px; word-wrap: break-word; box-shadow: 0 4px 12px rgba(0,0,0,0.6); border: 1px solid #444; z-index: 10000; pointer-events: none; opacity: 0; transition: opacity 0.2s ease-in-out; `; document.body.appendChild(this.tooltip); this.updateTooltipPosition(event); // Fade in the tooltip requestAnimationFrame(() => { if (this.tooltip) { this.tooltip.style.opacity = '1'; } }); } updateTooltipPosition(event) { if (!this.tooltip) return; const tooltipRect = this.tooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let x = event.clientX + 10; let y = event.clientY - 10; // Adjust if tooltip would go off the right edge if (x + tooltipRect.width > viewportWidth) { x = event.clientX - tooltipRect.width - 10; } // Adjust if tooltip would go off the bottom edge if (y + tooltipRect.height > viewportHeight) { y = event.clientY - tooltipRect.height - 10; } // Ensure tooltip doesn't go off the left or top edges x = Math.max(5, x); y = Math.max(5, y); this.tooltip.style.left = `${x}px`; this.tooltip.style.top = `${y}px`; } hideTooltip() { if (this.tooltip) { this.tooltip.remove(); this.tooltip = null; } } _updateCanvasSize() { if (!this.canvas.outputAreaExtensionEnabled) { // Reset to original bounds when disabled this.canvas.outputAreaBounds = { x: 0, y: 0, width: this.canvas.originalCanvasSize.width, height: this.canvas.originalCanvasSize.height }; this.canvas.updateOutputAreaSize(this.canvas.originalCanvasSize.width, this.canvas.originalCanvasSize.height, false); return; } const ext = this.canvas.outputAreaExtensions; const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right; const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom; // Aktualizuj outputAreaBounds - "okno" w świecie które zostanie wyrenderowane this.canvas.outputAreaBounds = { x: -ext.left, // Może być ujemne - wycinamy fragment świata y: -ext.top, // Może być ujemne - wycinamy fragment świata width: newWidth, height: newHeight }; // Zmień rozmiar canvas (fizyczny rozmiar dla renderowania) this.canvas.updateOutputAreaSize(newWidth, newHeight, false); log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`); log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`); } }