export class MaskPainterManager { constructor(node) { this.node = node; this.isDrawing = false; this.lastPoint = { x: 0, y: 0 }; this.settings = { size: 30, hardness: 80, opacity: 100, mode: 'brush' }; this.maskCanvas = document.createElement('canvas'); this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); this.liveOverlay = null; this.liveCtx = null; this.boundHandleMouseEvent = this.handleMouseEvent.bind(this); this.createSettingsToolbar(); } createSettingsToolbar() { if (this.toolbar) this.toolbar.remove(); const styleId = 'ls-mask-painter-style'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.innerHTML = ` .ls-mask-painter-toolbar .ls-tool-button { border: 1px solid white; background-color: transparent; color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; font-size: 18px; width: 32px; height: 32px; padding: 2px; } .ls-mask-painter-toolbar .ls-tool-button:not(.active):hover { background-color: rgba(255, 255, 255, 0.15); } .ls-mask-painter-toolbar .ls-tool-button.active { background-color: rgba(100, 180, 255, 0.4); border-color: #64b4ff; } `; document.head.appendChild(style); } this.toolbar = document.createElement("div"); this.toolbar.className = 'ls-mask-painter-toolbar'; Object.assign(this.toolbar.style, { position: 'fixed', display: 'none', zIndex: '10002', backgroundColor: 'rgba(40, 40, 40, 0.9)', border: '1px solid #555', borderRadius: '8px', padding: '8px', alignItems: 'center', gap: '6px', color: 'white', fontFamily: 'sans-serif' }); this.toolbar.innerHTML = ` ${this.settings.size} ${this.settings.hardness}% ${this.settings.opacity}% `; document.body.appendChild(this.toolbar); this.toolbar.querySelector('[data-mode="brush"]').classList.add('active'); this.toolbar.addEventListener('input', (e) => { const setting = e.target.dataset.setting; if (setting) { this.settings[setting] = parseInt(e.target.value, 10); const suffix = (setting === 'hardness' || setting === 'opacity') ? '%' : ''; this.toolbar.querySelector(`span[data-value="${setting}"]`).textContent = e.target.value + suffix; } }); this.toolbar.addEventListener('click', (e) => { const target = e.target.closest('button'); if (!target) return; if (target.dataset.mode) { this.settings.mode = target.dataset.mode; this.toolbar.querySelectorAll('.ls-tool-button').forEach(btn => btn.classList.remove('active')); target.classList.add('active'); } if (target.dataset.action === 'apply') this.finalizeDrawing(); if (target.dataset.action === 'cancel') this.hide(); }); } async show() { this.node.toolbar.activeTool = 'mask_painter'; this.activeLayer = this.node.getActiveLayer(); if (!this.activeLayer) { alert("Please select a layer to paint a mask on."); return; } const layerImage = this.node.loaded_preview_images[this.activeLayer.name]; if (!layerImage || !layerImage.naturalWidth) return; const props = this.node.layer_properties[this.activeLayer.name]; props.enabled = false; this.node.refreshUI(); const preview = this.node.previewCanvas; this.maskCanvas.width = layerImage.naturalWidth; this.maskCanvas.height = layerImage.naturalHeight; this.liveOverlay = document.createElement('canvas'); Object.assign(this.liveOverlay.style, { position: 'absolute', top: '0', left: '0', zIndex: '10001', pointerEvents: 'auto', cursor: 'none' }); this.liveOverlay.width = preview.width; this.liveOverlay.height = preview.height; this.liveCtx = this.liveOverlay.getContext('2d'); preview.parentElement.appendChild(this.liveOverlay); this.liveOverlay.addEventListener('mousedown', this.boundHandleMouseEvent); this.liveOverlay.addEventListener('mousemove', this.boundHandleMouseEvent); this.liveOverlay.addEventListener('mouseup', this.boundHandleMouseEvent); this.liveOverlay.addEventListener('mouseleave', this.boundHandleMouseEvent); this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); if (props.internal_mask_details) { const details = props.internal_mask_details; const url = new URL("/view", window.location.origin); url.searchParams.set("filename", details.name); url.searchParams.set("type", details.type); url.searchParams.set("t", Date.now()); const existingMask = new Image(); existingMask.crossOrigin = "anonymous"; existingMask.src = url.href; await new Promise(resolve => { existingMask.onload = resolve; existingMask.onerror = resolve; }); const tempCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); tempCtx.canvas.width = this.maskCanvas.width; tempCtx.canvas.height = this.maskCanvas.height; tempCtx.drawImage(existingMask, 0, 0, tempCtx.canvas.width, tempCtx.canvas.height); const imageData = tempCtx.getImageData(0, 0, tempCtx.canvas.width, tempCtx.canvas.height); const data = imageData.data; let hasAlpha = false; for (let i = 3; i < data.length; i += 4) { if (data[i] < 255) { hasAlpha = true; break; } } if (hasAlpha) { for (let i = 0; i < data.length; i += 4) { data[i + 3] = 255 - data[i + 3]; data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; } } else { for (let i = 0; i < data.length; i += 4) { data[i + 3] = 255 - data[i]; data[i] = data[i+1] = data[i+2] = 255; } } this.maskCtx.putImageData(imageData, 0, 0); } else { this.maskCtx.fillStyle = 'white'; this.maskCtx.fillRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); } this.updateLivePreview(); this.toolbar.style.display = 'flex'; this.positionToolbar(); } drawStamp(point) { const ctx = this.maskCtx; const radius = this.settings.size / 2; const gradient = ctx.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius); const hardnessStop = Math.max(0, Math.min(1, this.settings.hardness / 100)); ctx.save(); ctx.globalAlpha = this.settings.opacity / 100; if (this.settings.mode === 'brush') { gradient.addColorStop(0, 'white'); gradient.addColorStop(hardnessStop, 'white'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = gradient; } else { gradient.addColorStop(0, 'black'); gradient.addColorStop(hardnessStop, 'black'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = gradient; } ctx.beginPath(); ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI); ctx.fill(); ctx.restore(); } hide() { if (this.activeLayer) { const props = this.node.layer_properties[this.activeLayer.name]; if (props) { props.enabled = true; this.node.updatePropertiesJSON(); } } this.toolbar.style.display = 'none'; if (this.liveOverlay) { this.liveOverlay.remove(); this.liveOverlay = null; this.liveCtx = null; } if (this.node.toolbar.activeTool === 'mask_painter') { this.node.toolbar.activeTool = null; } this.node.refreshUI(); } async switchLayer() { this.hide(); await new Promise(resolve => setTimeout(resolve, 0)); this.show(); } async finalizeDrawing() { const applyButton = this.toolbar.querySelector('[data-action="apply"]'); applyButton.textContent = "Applying..."; applyButton.disabled = true; try { const blob = await new Promise(resolve => this.maskCanvas.toBlob(resolve, 'image/png')); const file = new File([blob], `temp_alpha_mask_${this.activeLayer.index}.png`, { type: "image/png" }); const formData = new FormData(); formData.append("image", file); formData.append("overwrite", "true"); formData.append("type", "input"); let response = await fetch("/upload/image", { method: "POST", body: formData }); const tempAlphaMaskDetails = await response.json(); response = await fetch("/layersystem/finalize_painter_mask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ alpha_mask_details: tempAlphaMaskDetails, layer_index: this.activeLayer.index }), }); if (!response.ok) { throw new Error(`Server error: ${await response.text()}`); } const finalMasks = await response.json(); const props = this.node.layer_properties[this.activeLayer.name]; props.internal_mask_filename = finalMasks.render_mask_details.name; props.internal_mask_details = finalMasks.render_mask_details; props.internal_preview_mask_details = finalMasks.preview_mask_details; props.mask_last_update = Date.now(); this.node.updatePropertiesJSON(); const newMaskImage = new Image(); const previewUrl = new URL("/view", window.location.origin); previewUrl.searchParams.set("filename", finalMasks.preview_mask_details.name); previewUrl.searchParams.set("type", "input"); previewUrl.searchParams.set("t", props.mask_last_update); newMaskImage.src = previewUrl.href; await new Promise(resolve => newMaskImage.onload = resolve); this.node.loaded_preview_images[this.activeLayer.name.replace('layer_', 'mask_')] = newMaskImage; } catch (e) { console.error("Failed to apply mask drawing:", e); } finally { applyButton.textContent = "Apply Mask"; applyButton.disabled = false; this.hide(); } } getOriginalCoords(e) { const props = this.node.layer_properties[this.activeLayer.name]; const layerImage = this.node.loaded_preview_images[this.activeLayer.name]; const preview = this.node.previewCanvas; const toolbar = this.node.toolbar; const baseImage = this.node.basePreviewImage; const previewCanvasScale = (preview.width - toolbar.width) / baseImage.naturalWidth; const imageAreaCenterX = toolbar.width + (preview.width - toolbar.width) / 2; const imageAreaCenterY = preview.height / 2; const transformedWidth = layerImage.naturalWidth * props.scale * previewCanvasScale; const transformedHeight = layerImage.naturalHeight * props.scale * previewCanvasScale; const centerX = imageAreaCenterX + (props.offset_x * previewCanvasScale); const centerY = imageAreaCenterY + (props.offset_y * previewCanvasScale); const dx = e.offsetX - centerX; const dy = e.offsetY - centerY; const angleRad = -(props.rotation || 0) * Math.PI / 180; const unrotatedDx = dx * Math.cos(angleRad) - dy * Math.sin(angleRad); const unrotatedDy = dx * Math.sin(angleRad) + dy * Math.cos(angleRad); const localX = unrotatedDx + transformedWidth / 2; const localY = unrotatedDy + transformedHeight / 2; return { x: localX / (transformedWidth / layerImage.naturalWidth), y: localY / (transformedHeight / layerImage.naturalHeight) }; } handleMouseEvent(e) { if (!this.activeLayer) return; const mainCanvasRect = this.node.previewCanvas.getBoundingClientRect(); const zoom = app.canvas.ds.scale || 1; const mouseX = (e.clientX - mainCanvasRect.left) / zoom; const mouseY = (e.clientY - mainCanvasRect.top) / zoom; const isOverToolbar = this.node.toolbar.isClickOnToolbar(mouseX, mouseY); if (isOverToolbar) { this.liveOverlay.style.cursor = 'default'; this.updateLivePreview(null); if (e.type === 'mousedown') { this.node.toolbar.handleClick(e, mouseX, mouseY); } return; } this.liveOverlay.style.cursor = 'none'; if (e.type === 'mousedown') { this.isDrawing = true; const coords = this.getOriginalCoords(e); this.lastPoint = coords; this.drawStamp(coords); } else if (e.type === 'mousemove') { if (this.isDrawing) { const coords = this.getOriginalCoords(e); const dist = Math.hypot(coords.x - this.lastPoint.x, coords.y - this.lastPoint.y); const spacing = this.settings.size / 2; if (dist >= spacing) { this.drawStroke(this.lastPoint, coords); this.lastPoint = coords; } } } else if (e.type === 'mouseup' || e.type === 'mouseleave') { this.isDrawing = false; } this.updateLivePreview(e); } drawStroke(from, to) { const dist = Math.hypot(to.x - from.x, to.y - from.y); const angle = Math.atan2(to.y - from.y, to.x - from.x); const step = this.settings.size / 4; for (let i = 0; i < dist; i += step) { const x = from.x + (Math.cos(angle) * i); const y = from.y + (Math.sin(angle) * i); this.drawStamp({ x, y }); } this.drawStamp(to); } updateLivePreview(mouseEvent = null) { if (!this.liveCtx) return; this.liveCtx.clearRect(0, 0, this.liveOverlay.width, this.liveOverlay.height); const tempLayerCanvas = document.createElement('canvas'); tempLayerCanvas.width = this.maskCanvas.width; tempLayerCanvas.height = this.maskCanvas.height; const tempLayerCtx = tempLayerCanvas.getContext('2d'); const layerImage = this.node.loaded_preview_images[this.activeLayer.name]; tempLayerCtx.drawImage(layerImage, 0, 0); tempLayerCtx.globalCompositeOperation = 'destination-in'; tempLayerCtx.drawImage(this.maskCanvas, 0, 0); const props = this.node.layer_properties[this.activeLayer.name]; const preview = this.node.previewCanvas; const toolbar = this.node.toolbar; const baseImage = this.node.basePreviewImage; const previewCanvasScale = (preview.width - toolbar.width) / baseImage.naturalWidth; const imageAreaCenterX = toolbar.width + (preview.width - toolbar.width) / 2; const imageAreaCenterY = preview.height / 2; const transformedWidth = layerImage.naturalWidth * props.scale * previewCanvasScale; const transformedHeight = layerImage.naturalHeight * props.scale * previewCanvasScale; const centerX = imageAreaCenterX + (props.offset_x * previewCanvasScale); const centerY = imageAreaCenterY + (props.offset_y * previewCanvasScale); const angleRad = (props.rotation || 0) * Math.PI / 180; this.liveCtx.save(); this.liveCtx.translate(centerX, centerY); this.liveCtx.rotate(angleRad); this.liveCtx.drawImage(tempLayerCanvas, -transformedWidth / 2, -transformedHeight / 2, transformedWidth, transformedHeight); this.liveCtx.restore(); if (mouseEvent) { const previewSize = (this.settings.size / this.maskCanvas.width) * transformedWidth; this.liveCtx.beginPath(); this.liveCtx.arc(mouseEvent.offsetX, mouseEvent.offsetY, previewSize / 2, 0, 2 * Math.PI); this.liveCtx.strokeStyle = 'white'; this.liveCtx.lineWidth = 1; this.liveCtx.setLineDash([2, 2]); this.liveCtx.stroke(); this.liveCtx.setLineDash([]); } } positionToolbar() { if (!this.node.previewCanvas) return; const canvasRect = this.node.previewCanvas.getBoundingClientRect(); this.toolbar.style.left = `${canvasRect.left}px`; this.toolbar.style.top = `${canvasRect.top - this.toolbar.offsetHeight - 5}px`; } }