From de83a884c2e69d9074a33c30089da147748f4b42 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 8 Aug 2025 17:13:44 +0200 Subject: [PATCH] Switch mask preview from chunked to canvas rendering Replaced chunked rendering approach with direct canvas drawing for mask preview, then applying to main canvas. Added "Mask Opacity" slider. --- js/CanvasInteractions.js | 16 ++- js/CanvasRenderer.js | 175 ++++++++++++++++++++++++++++----- js/CanvasView.js | 19 ++++ js/MaskTool.js | 147 +++++++++++++++++++++++----- src/CanvasInteractions.ts | 19 +++- src/CanvasRenderer.ts | 199 ++++++++++++++++++++++++++++++++------ src/CanvasView.ts | 18 ++++ src/MaskTool.ts | 174 ++++++++++++++++++++++++++++----- 8 files changed, 653 insertions(+), 114 deletions(-) diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 8248ebc..250619e 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -68,6 +68,10 @@ export class CanvasInteractions { this.canvas.viewport.zoom = newZoom; this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); + // Update stroke overlay if mask tool is drawing during zoom + if (this.canvas.maskTool.isDrawing) { + this.canvas.maskTool.handleViewportChange(); + } this.canvas.onViewportChange?.(); } renderAndSave(shouldSave = false) { @@ -161,7 +165,7 @@ export class CanvasInteractions { const mods = this.getModifierState(e); if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseDown(coords.world, coords.view); - this.canvas.render(); + // Don't render here - mask tool will handle its own drawing return; } if (this.canvas.shapeTool.isActive) { @@ -234,10 +238,7 @@ export class CanvasInteractions { switch (this.interaction.mode) { case 'drawingMask': this.canvas.maskTool.handleMouseMove(coords.world, coords.view); - // Only render if actually drawing, not just moving cursor - if (this.canvas.maskTool.isDrawing) { - this.canvas.render(); - } + // Don't render during mask drawing - it's handled by mask tool internally break; case 'panning': this.panViewport(e); @@ -274,6 +275,7 @@ export class CanvasInteractions { const coords = this.getMouseCoordinates(e); if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(coords.view); + // Render only once after drawing is complete this.canvas.render(); return; } @@ -712,6 +714,10 @@ export class CanvasInteractions { this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.interaction.panStart = { x: e.clientX, y: e.clientY }; + // Update stroke overlay if mask tool is drawing during pan + if (this.canvas.maskTool.isDrawing) { + this.canvas.maskTool.handleViewportChange(); + } this.canvas.render(); this.canvas.onViewportChange?.(); } diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index ce350a5..38938e4 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -7,8 +7,9 @@ export class CanvasRenderer { this.lastRenderTime = 0; this.renderInterval = 1000 / 60; this.isDirty = false; - // Initialize overlay canvas + // Initialize overlay canvases this.initOverlay(); + this.initStrokeOverlay(); } /** * Helper function to draw text with background at world coordinates @@ -104,10 +105,12 @@ export class CanvasRenderer { if (maskImage && this.canvas.maskTool.isOverlayVisible) { ctx.save(); if (this.canvas.maskTool.isActive) { + // In draw mask mode, use the previewOpacity value from the slider ctx.globalCompositeOperation = 'source-over'; - ctx.globalAlpha = 0.5; + ctx.globalAlpha = this.canvas.maskTool.previewOpacity; } else { + // When not in draw mask mode, show mask at full opacity ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1.0; } @@ -160,9 +163,11 @@ export class CanvasRenderer { this.canvas.canvas.height = this.canvas.offscreenCanvas.height; } this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); - // Ensure overlay canvas is in DOM and properly sized + // Ensure overlay canvases are in DOM and properly sized this.addOverlayToDOM(); this.updateOverlaySize(); + this.addStrokeOverlayToDOM(); + this.updateStrokeOverlaySize(); // Update Batch Preview UI positions if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { this.canvas.batchPreviewManagers.forEach((manager) => { @@ -630,6 +635,121 @@ export class CanvasRenderer { clearOverlay() { this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height); } + /** + * Initialize a dedicated overlay for real-time mask stroke preview + */ + initStrokeOverlay() { + // Create canvas if not created yet + if (!this.strokeOverlayCanvas) { + this.strokeOverlayCanvas = document.createElement('canvas'); + const ctx = this.strokeOverlayCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2D context for stroke overlay canvas'); + } + this.strokeOverlayCtx = ctx; + } + // Size match main canvas + this.updateStrokeOverlaySize(); + // Position above main canvas but below cursor overlay + this.strokeOverlayCanvas.style.position = 'absolute'; + this.strokeOverlayCanvas.style.left = '1px'; + this.strokeOverlayCanvas.style.top = '1px'; + this.strokeOverlayCanvas.style.pointerEvents = 'none'; + this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20) + // Opacity is now controlled by MaskTool.previewOpacity + this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5); + // Add to DOM + this.addStrokeOverlayToDOM(); + log.debug('Stroke overlay canvas initialized'); + } + /** + * Add stroke overlay canvas to DOM if needed + */ + addStrokeOverlayToDOM() { + if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) { + this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas); + log.debug('Stroke overlay canvas added to DOM'); + } + } + /** + * Ensure stroke overlay size matches main canvas + */ + updateStrokeOverlaySize() { + const w = Math.max(1, this.canvas.canvas.clientWidth); + const h = Math.max(1, this.canvas.canvas.clientHeight); + if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) { + this.strokeOverlayCanvas.width = w; + this.strokeOverlayCanvas.height = h; + log.debug(`Stroke overlay resized to ${w}x${h}`); + } + } + /** + * Clear the stroke overlay + */ + clearMaskStrokeOverlay() { + if (!this.strokeOverlayCtx) + return; + this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height); + } + /** + * Draw a preview stroke segment onto the stroke overlay in screen space + * Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly + */ + drawMaskStrokeSegment(startWorld, endWorld) { + // Ensure overlay is present and sized + this.updateStrokeOverlaySize(); + const zoom = this.canvas.viewport.zoom; + const toScreen = (p) => ({ + x: (p.x - this.canvas.viewport.x) * zoom, + y: (p.y - this.canvas.viewport.y) * zoom + }); + const startScreen = toScreen(startWorld); + const endScreen = toScreen(endWorld); + const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom; + const hardness = this.canvas.maskTool.brushHardness; + const strength = this.canvas.maskTool.brushStrength; + // If strength is 0, don't draw anything + if (strength <= 0) { + return; + } + this.strokeOverlayCtx.save(); + // Draw line segment exactly as MaskTool does + this.strokeOverlayCtx.beginPath(); + this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y); + this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y); + // Match the gradient setup from MaskTool's drawLineOnChunk + if (hardness === 1) { + this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`; + } + else { + const innerRadius = brushRadius * hardness; + const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius); + gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`); + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + this.strokeOverlayCtx.strokeStyle = gradient; + } + // Match line properties from MaskTool + this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom; + this.strokeOverlayCtx.lineCap = 'round'; + this.strokeOverlayCtx.lineJoin = 'round'; + this.strokeOverlayCtx.globalCompositeOperation = 'source-over'; + this.strokeOverlayCtx.stroke(); + this.strokeOverlayCtx.restore(); + } + /** + * Redraws the entire stroke overlay from world coordinates + * Used when viewport changes during drawing to maintain visual consistency + */ + redrawMaskStrokeOverlay(strokePoints) { + if (strokePoints.length < 2) + return; + // Clear the overlay first + this.clearMaskStrokeOverlay(); + // Redraw all segments with current viewport + for (let i = 1; i < strokePoints.length; i++) { + this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]); + } + } /** * Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness * @param worldPoint World coordinates of cursor @@ -652,46 +772,49 @@ export class CanvasRenderer { const brushHardness = this.canvas.maskTool.brushHardness; // Save context state this.canvas.overlayCtx.save(); - // 1. Draw inner fill to visualize STRENGTH (opacity) - // Higher strength = more opaque fill - this.canvas.overlayCtx.beginPath(); - this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); - this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${brushStrength * 0.2})`; // Max 20% opacity for visibility - this.canvas.overlayCtx.fill(); - // 2. Draw gradient edge to visualize HARDNESS - // Hard brush = sharp edge, Soft brush = gradient edge - if (brushHardness < 1) { - // Create radial gradient for soft brushes - const innerRadius = brushRadius * brushHardness; - const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, innerRadius, screenX, screenY, brushRadius); - // Inner part is solid - gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + brushStrength * 0.3})`); - // Outer part fades based on hardness - gradient.addColorStop(1, `rgba(255, 255, 255, ${0.05})`); + // If strength is 0, just draw outline + if (brushStrength > 0) { + // Draw inner fill to visualize brush effect - matches actual brush rendering + const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius); + // Preview alpha - subtle to not obscure content + const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity) + if (brushHardness === 1) { + // Hard brush - uniform fill within radius + gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`); + gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`); + } + else { + // Soft brush - gradient fade matching actual brush + gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`); + if (brushHardness > 0) { + gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`); + } + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + } this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); this.canvas.overlayCtx.fillStyle = gradient; this.canvas.overlayCtx.fill(); } - // 3. Draw outer circle (SIZE indicator) + // Draw outer circle (SIZE indicator) this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); - // Make the stroke opacity also reflect strength slightly - const strokeOpacity = 0.4 + brushStrength * 0.4; // Range from 0.4 to 0.8 + // Stroke opacity based on strength (dimmer when strength is 0) + const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3; this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`; this.canvas.overlayCtx.lineWidth = 1.5; - // Use solid line for hard brushes, dashed for soft brushes + // Visual feedback for hardness if (brushHardness > 0.8) { // Hard brush - solid line this.canvas.overlayCtx.setLineDash([]); } else { - // Soft brush - dashed line, dash length based on hardness - const dashLength = 2 + (1 - brushHardness) * 4; // Longer dashes for softer brushes + // Soft brush - dashed line + const dashLength = 2 + (1 - brushHardness) * 4; this.canvas.overlayCtx.setLineDash([dashLength, dashLength]); } this.canvas.overlayCtx.stroke(); - // 4. Optional: Draw center dot for very precise brushes + // Center dot for small brushes if (brushRadius < 5) { this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI); diff --git a/js/CanvasView.js b/js/CanvasView.js index 4bd654e..e21611d 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -554,6 +554,25 @@ async function createCanvasWidget(node, widget, app) { setTimeout(() => canvas.render(), 0); } }), + $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ + $el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }), + $el("input", { + id: "preview-opacity-slider", + type: "range", + min: "0", + max: "1", + step: "0.05", + value: "0.5", + oninput: (e) => { + const value = e.target.value; + canvas.maskTool.setPreviewOpacity(parseFloat(value)); + const valueEl = document.getElementById('preview-opacity-value'); + if (valueEl) + valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`; + } + }), + $el("div.slider-value", { id: "preview-opacity-value" }, ["50%"]) + ]), $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ $el("label", { for: "brush-size-slider", textContent: "Size:" }), $el("input", { diff --git a/js/MaskTool.js b/js/MaskTool.js index 7b99753..0942f6c 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js"; const log = createModuleLogger('Mask_tool'); export class MaskTool { constructor(canvasInstance, callbacks = {}) { + // Track strokes during drawing for efficient overlay updates + this.currentStrokePoints = []; this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; + // Initialize stroke tracking for overlay drawing + this.currentStrokePoints = []; // Initialize chunked mask system this.maskChunks = new Map(); this.chunkSize = 512; @@ -30,6 +34,7 @@ export class MaskTool { this.brushSize = 20; this._brushStrength = 0.5; this._brushHardness = 0.5; + this._previewOpacity = 0.5; // Default 50% opacity for preview this.isDrawing = false; this.lastPosition = null; const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true }); @@ -86,9 +91,21 @@ export class MaskTool { get brushHardness() { return this._brushHardness; } + get previewOpacity() { + return this._previewOpacity; + } setBrushHardness(hardness) { this._brushHardness = Math.max(0, Math.min(1, hardness)); } + setPreviewOpacity(opacity) { + this._previewOpacity = Math.max(0, Math.min(1, opacity)); + // Update the stroke overlay canvas opacity when preview opacity changes + if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) { + this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity); + } + // Trigger canvas render to update mask display opacity + this.canvasInstance.render(); + } initMaskCanvas() { // Initialize chunked system this.chunkSize = 512; @@ -685,9 +702,10 @@ export class MaskTool { return; this.isDrawing = true; this.lastPosition = worldCoords; - // Activate chunks around the drawing position for performance - this.updateActiveChunksForDrawing(worldCoords); - this.draw(worldCoords); + // Initialize stroke tracking for live preview + this.currentStrokePoints = [worldCoords]; + // Clear any previous stroke overlay + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); this.clearPreview(); } handleMouseMove(worldCoords, viewCoords) { @@ -696,16 +714,69 @@ export class MaskTool { } if (!this.isActive || !this.isDrawing) return; - // Dynamically update active chunks as user moves while drawing - this.updateActiveChunksForDrawing(worldCoords); - this.draw(worldCoords); + // Add point to stroke tracking + this.currentStrokePoints.push(worldCoords); + // Draw interpolated segments for smooth strokes without gaps + if (this.lastPosition) { + // Calculate distance between last and current position + const dx = worldCoords.x - this.lastPosition.x; + const dy = worldCoords.y - this.lastPosition.y; + const distance = Math.sqrt(dx * dx + dy * dy); + // If distance is small, just draw a single segment + if (distance < this.brushSize / 4) { + this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords); + } + else { + // Interpolate points for smooth drawing without gaps + const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance); + // Draw all interpolated segments + for (let i = 0; i < interpolatedPoints.length - 1; i++) { + this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]); + } + } + } this.lastPosition = worldCoords; } + /** + * Interpolates points between two positions to create smooth strokes without gaps + * Based on the BrushTool's approach for eliminating dotted lines during fast drawing + */ + interpolatePoints(start, end, distance) { + const points = []; + // Calculate number of interpolated points based on brush size + // More points = smoother line + const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness + const numSteps = Math.ceil(distance / stepSize); + // Always include start point + points.push(start); + // Interpolate intermediate points + for (let i = 1; i < numSteps; i++) { + const t = i / numSteps; + points.push({ + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t + }); + } + // Always include end point + points.push(end); + return points; + } + /** + * Called when viewport changes during drawing to update stroke overlay + * This ensures the stroke preview scales correctly with zoom changes + */ + handleViewportChange() { + if (this.isDrawing && this.currentStrokePoints.length > 1) { + // Redraw the entire stroke overlay with new viewport settings + this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints); + } + } handleMouseLeave() { this.previewVisible = false; this.clearPreview(); - // Clear overlay canvas when mouse leaves + // Clear overlay canvases when mouse leaves this.canvasInstance.canvasRenderer.clearOverlay(); + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); } handleMouseEnter() { this.previewVisible = true; @@ -715,10 +786,15 @@ export class MaskTool { return; if (this.isDrawing) { this.isDrawing = false; + // Commit the stroke from overlay to actual mask chunks + this.commitStrokeToChunks(); + // Clear stroke overlay and reset state + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); + this.currentStrokePoints = []; this.lastPosition = null; this.currentDrawingChunk = null; // After drawing is complete, update active canvas to show all chunks - this.updateActiveMaskCanvas(true); // forceShowAll = true + this.updateActiveMaskCanvas(true); // Force full update this.completeMaskOperation(); this.drawBrushPreview(viewCoords); } @@ -733,6 +809,38 @@ export class MaskTool { // This prevents unnecessary recomposition during drawing this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); } + /** + * Commits the current stroke from overlay to actual mask chunks + * This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy + */ + commitStrokeToChunks() { + if (this.currentStrokePoints.length < 2) { + return; // Need at least 2 points for a stroke + } + log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`); + // Replay the entire stroke path with interpolation for smooth, accurate lines + for (let i = 1; i < this.currentStrokePoints.length; i++) { + const startPoint = this.currentStrokePoints[i - 1]; + const endPoint = this.currentStrokePoints[i]; + // Calculate distance between points + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < this.brushSize / 4) { + // Small distance - draw single segment + this.drawOnChunks(startPoint, endPoint); + } + else { + // Large distance - interpolate for smooth line without gaps + const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance); + // Draw all interpolated segments + for (let j = 0; j < interpolatedPoints.length - 1; j++) { + this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]); + } + } + } + log.debug("Stroke committed to chunks successfully with interpolation"); + } /** * Draws a line between two world coordinates on the appropriate chunks */ @@ -814,28 +922,17 @@ export class MaskTool { return true; // For now, always draw - more precise intersection can be added later } /** - * Updates active canvas when drawing affects chunks with throttling to prevent lag - * During drawing, only updates the affected active chunks for performance + * Updates active canvas when drawing affects chunks + * Since we now use overlay during drawing, this is only called after drawing is complete */ updateActiveCanvasIfNeeded(startWorld, endWorld) { - // Calculate which chunks were affected by this drawing operation - const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; - const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; - const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; - const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; - const affectedChunkMinX = Math.floor(minX / this.chunkSize); - const affectedChunkMinY = Math.floor(minY / this.chunkSize); - const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); - const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); - // During drawing, only update affected chunks that are active for performance - if (this.isDrawing) { - // Use throttled partial update for active chunks only - this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); - } - else { + // This method is now simplified - we only update after drawing is complete + // The overlay handles all live preview, so we don't need complex chunk activation + if (!this.isDrawing) { // Not drawing - do full update to show all chunks this.updateActiveMaskCanvas(true); } + // During drawing, we don't update chunks at all - overlay handles preview } /** * Schedules a throttled update of the active mask canvas to prevent excessive redraws diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index a60de62..de92bbc 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -131,6 +131,11 @@ export class CanvasInteractions { this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); + // Update stroke overlay if mask tool is drawing during zoom + if (this.canvas.maskTool.isDrawing) { + this.canvas.maskTool.handleViewportChange(); + } + this.canvas.onViewportChange?.(); } @@ -243,7 +248,7 @@ export class CanvasInteractions { if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseDown(coords.world, coords.view); - this.canvas.render(); + // Don't render here - mask tool will handle its own drawing return; } @@ -327,10 +332,7 @@ export class CanvasInteractions { switch (this.interaction.mode) { case 'drawingMask': this.canvas.maskTool.handleMouseMove(coords.world, coords.view); - // Only render if actually drawing, not just moving cursor - if (this.canvas.maskTool.isDrawing) { - this.canvas.render(); - } + // Don't render during mask drawing - it's handled by mask tool internally break; case 'panning': this.panViewport(e); @@ -370,6 +372,7 @@ export class CanvasInteractions { if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(coords.view); + // Render only once after drawing is complete this.canvas.render(); return; } @@ -840,6 +843,12 @@ export class CanvasInteractions { this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.interaction.panStart = {x: e.clientX, y: e.clientY}; + + // Update stroke overlay if mask tool is drawing during pan + if (this.canvas.maskTool.isDrawing) { + this.canvas.maskTool.handleViewportChange(); + } + this.canvas.render(); this.canvas.onViewportChange?.(); } diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index 6b66950..0c6a368 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -8,6 +8,9 @@ export class CanvasRenderer { lastRenderTime: any; renderAnimationFrame: any; renderInterval: any; + // Overlay used to preview in-progress mask strokes (separate from cursor overlay) + strokeOverlayCanvas!: HTMLCanvasElement; + strokeOverlayCtx!: CanvasRenderingContext2D; constructor(canvas: any) { this.canvas = canvas; this.renderAnimationFrame = null; @@ -15,8 +18,9 @@ export class CanvasRenderer { this.renderInterval = 1000 / 60; this.isDirty = false; - // Initialize overlay canvas + // Initialize overlay canvases this.initOverlay(); + this.initStrokeOverlay(); } /** @@ -144,9 +148,11 @@ export class CanvasRenderer { ctx.save(); if (this.canvas.maskTool.isActive) { + // In draw mask mode, use the previewOpacity value from the slider ctx.globalCompositeOperation = 'source-over'; - ctx.globalAlpha = 0.5; + ctx.globalAlpha = this.canvas.maskTool.previewOpacity; } else { + // When not in draw mask mode, show mask at full opacity ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1.0; } @@ -208,9 +214,11 @@ export class CanvasRenderer { } this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); - // Ensure overlay canvas is in DOM and properly sized + // Ensure overlay canvases are in DOM and properly sized this.addOverlayToDOM(); this.updateOverlaySize(); + this.addStrokeOverlayToDOM(); + this.updateStrokeOverlaySize(); // Update Batch Preview UI positions if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { @@ -769,6 +777,141 @@ export class CanvasRenderer { this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height); } + /** + * Initialize a dedicated overlay for real-time mask stroke preview + */ + initStrokeOverlay(): void { + // Create canvas if not created yet + if (!this.strokeOverlayCanvas) { + this.strokeOverlayCanvas = document.createElement('canvas'); + const ctx = this.strokeOverlayCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2D context for stroke overlay canvas'); + } + this.strokeOverlayCtx = ctx; + } + + // Size match main canvas + this.updateStrokeOverlaySize(); + + // Position above main canvas but below cursor overlay + this.strokeOverlayCanvas.style.position = 'absolute'; + this.strokeOverlayCanvas.style.left = '1px'; + this.strokeOverlayCanvas.style.top = '1px'; + this.strokeOverlayCanvas.style.pointerEvents = 'none'; + this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20) + // Opacity is now controlled by MaskTool.previewOpacity + this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5); + + // Add to DOM + this.addStrokeOverlayToDOM(); + log.debug('Stroke overlay canvas initialized'); + } + + /** + * Add stroke overlay canvas to DOM if needed + */ + addStrokeOverlayToDOM(): void { + if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) { + this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas); + log.debug('Stroke overlay canvas added to DOM'); + } + } + + /** + * Ensure stroke overlay size matches main canvas + */ + updateStrokeOverlaySize(): void { + const w = Math.max(1, this.canvas.canvas.clientWidth); + const h = Math.max(1, this.canvas.canvas.clientHeight); + if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) { + this.strokeOverlayCanvas.width = w; + this.strokeOverlayCanvas.height = h; + log.debug(`Stroke overlay resized to ${w}x${h}`); + } + } + + /** + * Clear the stroke overlay + */ + clearMaskStrokeOverlay(): void { + if (!this.strokeOverlayCtx) return; + this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height); + } + + /** + * Draw a preview stroke segment onto the stroke overlay in screen space + * Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly + */ + drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void { + // Ensure overlay is present and sized + this.updateStrokeOverlaySize(); + + const zoom = this.canvas.viewport.zoom; + const toScreen = (p: { x: number; y: number }) => ({ + x: (p.x - this.canvas.viewport.x) * zoom, + y: (p.y - this.canvas.viewport.y) * zoom + }); + + const startScreen = toScreen(startWorld); + const endScreen = toScreen(endWorld); + + const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom; + const hardness = this.canvas.maskTool.brushHardness; + const strength = this.canvas.maskTool.brushStrength; + + // If strength is 0, don't draw anything + if (strength <= 0) { + return; + } + + this.strokeOverlayCtx.save(); + + // Draw line segment exactly as MaskTool does + this.strokeOverlayCtx.beginPath(); + this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y); + this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y); + + // Match the gradient setup from MaskTool's drawLineOnChunk + if (hardness === 1) { + this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`; + } else { + const innerRadius = brushRadius * hardness; + const gradient = this.strokeOverlayCtx.createRadialGradient( + endScreen.x, endScreen.y, innerRadius, + endScreen.x, endScreen.y, brushRadius + ); + gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`); + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + this.strokeOverlayCtx.strokeStyle = gradient; + } + + // Match line properties from MaskTool + this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom; + this.strokeOverlayCtx.lineCap = 'round'; + this.strokeOverlayCtx.lineJoin = 'round'; + this.strokeOverlayCtx.globalCompositeOperation = 'source-over'; + this.strokeOverlayCtx.stroke(); + + this.strokeOverlayCtx.restore(); + } + + /** + * Redraws the entire stroke overlay from world coordinates + * Used when viewport changes during drawing to maintain visual consistency + */ + redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void { + if (strokePoints.length < 2) return; + + // Clear the overlay first + this.clearMaskStrokeOverlay(); + + // Redraw all segments with current viewport + for (let i = 1; i < strokePoints.length; i++) { + this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]); + } + } + /** * Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness * @param worldPoint World coordinates of cursor @@ -797,27 +940,29 @@ export class CanvasRenderer { // Save context state this.canvas.overlayCtx.save(); - // 1. Draw inner fill to visualize STRENGTH (opacity) - // Higher strength = more opaque fill - this.canvas.overlayCtx.beginPath(); - this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); - this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${brushStrength * 0.2})`; // Max 20% opacity for visibility - this.canvas.overlayCtx.fill(); - - // 2. Draw gradient edge to visualize HARDNESS - // Hard brush = sharp edge, Soft brush = gradient edge - if (brushHardness < 1) { - // Create radial gradient for soft brushes - const innerRadius = brushRadius * brushHardness; + // If strength is 0, just draw outline + if (brushStrength > 0) { + // Draw inner fill to visualize brush effect - matches actual brush rendering const gradient = this.canvas.overlayCtx.createRadialGradient( - screenX, screenY, innerRadius, + screenX, screenY, 0, screenX, screenY, brushRadius ); - // Inner part is solid - gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + brushStrength * 0.3})`); - // Outer part fades based on hardness - gradient.addColorStop(1, `rgba(255, 255, 255, ${0.05})`); + // Preview alpha - subtle to not obscure content + const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity) + + if (brushHardness === 1) { + // Hard brush - uniform fill within radius + gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`); + gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`); + } else { + // Soft brush - gradient fade matching actual brush + gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`); + if (brushHardness > 0) { + gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`); + } + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + } this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); @@ -825,28 +970,28 @@ export class CanvasRenderer { this.canvas.overlayCtx.fill(); } - // 3. Draw outer circle (SIZE indicator) + // Draw outer circle (SIZE indicator) this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); - // Make the stroke opacity also reflect strength slightly - const strokeOpacity = 0.4 + brushStrength * 0.4; // Range from 0.4 to 0.8 + // Stroke opacity based on strength (dimmer when strength is 0) + const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3; this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`; this.canvas.overlayCtx.lineWidth = 1.5; - // Use solid line for hard brushes, dashed for soft brushes + // Visual feedback for hardness if (brushHardness > 0.8) { // Hard brush - solid line this.canvas.overlayCtx.setLineDash([]); } else { - // Soft brush - dashed line, dash length based on hardness - const dashLength = 2 + (1 - brushHardness) * 4; // Longer dashes for softer brushes + // Soft brush - dashed line + const dashLength = 2 + (1 - brushHardness) * 4; this.canvas.overlayCtx.setLineDash([dashLength, dashLength]); } this.canvas.overlayCtx.stroke(); - // 4. Optional: Draw center dot for very precise brushes + // Center dot for small brushes if (brushRadius < 5) { this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI); diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 321faf8..48f4b8c 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -640,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", { setTimeout(() => canvas.render(), 0); } }), + $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ + $el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}), + $el("input", { + id: "preview-opacity-slider", + type: "range", + min: "0", + max: "1", + step: "0.05", + value: "0.5", + oninput: (e: Event) => { + const value = (e.target as HTMLInputElement).value; + canvas.maskTool.setPreviewOpacity(parseFloat(value)); + const valueEl = document.getElementById('preview-opacity-value'); + if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`; + } + }), + $el("div.slider-value", {id: "preview-opacity-value"}, ["50%"]) + ]), $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ $el("label", {for: "brush-size-slider", textContent: "Size:"}), $el("input", { diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 089ba71..ed6d5cf 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -24,6 +24,7 @@ export class MaskTool { private _brushHardness: number; public brushSize: number; private _brushStrength: number; + private _previewOpacity: number; private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }; public isActive: boolean; public isDrawing: boolean; @@ -31,6 +32,9 @@ export class MaskTool { private lastPosition: Point | null; private mainCanvas: HTMLCanvasElement; + // Track strokes during drawing for efficient overlay updates + private currentStrokePoints: Point[] = []; + // Chunked mask system private maskChunks: Map; // Key: "x,y" (chunk coordinates) private chunkSize: number; @@ -72,6 +76,9 @@ export class MaskTool { this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; + // Initialize stroke tracking for overlay drawing + this.currentStrokePoints = []; + // Initialize chunked mask system this.maskChunks = new Map(); this.chunkSize = 512; @@ -98,6 +105,7 @@ export class MaskTool { this.brushSize = 20; this._brushStrength = 0.5; this._brushHardness = 0.5; + this._previewOpacity = 0.5; // Default 50% opacity for preview this.isDrawing = false; this.lastPosition = null; @@ -165,10 +173,24 @@ export class MaskTool { return this._brushHardness; } + get previewOpacity(): number { + return this._previewOpacity; + } + setBrushHardness(hardness: number): void { this._brushHardness = Math.max(0, Math.min(1, hardness)); } + setPreviewOpacity(opacity: number): void { + this._previewOpacity = Math.max(0, Math.min(1, opacity)); + // Update the stroke overlay canvas opacity when preview opacity changes + if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) { + this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity); + } + // Trigger canvas render to update mask display opacity + this.canvasInstance.render(); + } + initMaskCanvas(): void { // Initialize chunked system this.chunkSize = 512; @@ -884,10 +906,12 @@ export class MaskTool { this.isDrawing = true; this.lastPosition = worldCoords; - // Activate chunks around the drawing position for performance - this.updateActiveChunksForDrawing(worldCoords); + // Initialize stroke tracking for live preview + this.currentStrokePoints = [worldCoords]; + + // Clear any previous stroke overlay + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); - this.draw(worldCoords); this.clearPreview(); } @@ -897,18 +921,83 @@ export class MaskTool { } if (!this.isActive || !this.isDrawing) return; - // Dynamically update active chunks as user moves while drawing - this.updateActiveChunksForDrawing(worldCoords); + // Add point to stroke tracking + this.currentStrokePoints.push(worldCoords); + + // Draw interpolated segments for smooth strokes without gaps + if (this.lastPosition) { + // Calculate distance between last and current position + const dx = worldCoords.x - this.lastPosition.x; + const dy = worldCoords.y - this.lastPosition.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If distance is small, just draw a single segment + if (distance < this.brushSize / 4) { + this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords); + } else { + // Interpolate points for smooth drawing without gaps + const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance); + + // Draw all interpolated segments + for (let i = 0; i < interpolatedPoints.length - 1; i++) { + this.canvasInstance.canvasRenderer.drawMaskStrokeSegment( + interpolatedPoints[i], + interpolatedPoints[i + 1] + ); + } + } + } - this.draw(worldCoords); this.lastPosition = worldCoords; } + /** + * Interpolates points between two positions to create smooth strokes without gaps + * Based on the BrushTool's approach for eliminating dotted lines during fast drawing + */ + private interpolatePoints(start: Point, end: Point, distance: number): Point[] { + const points: Point[] = []; + + // Calculate number of interpolated points based on brush size + // More points = smoother line + const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness + const numSteps = Math.ceil(distance / stepSize); + + // Always include start point + points.push(start); + + // Interpolate intermediate points + for (let i = 1; i < numSteps; i++) { + const t = i / numSteps; + points.push({ + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t + }); + } + + // Always include end point + points.push(end); + + return points; + } + + /** + * Called when viewport changes during drawing to update stroke overlay + * This ensures the stroke preview scales correctly with zoom changes + */ + handleViewportChange(): void { + if (this.isDrawing && this.currentStrokePoints.length > 1) { + // Redraw the entire stroke overlay with new viewport settings + this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints); + } + } + handleMouseLeave(): void { this.previewVisible = false; this.clearPreview(); - // Clear overlay canvas when mouse leaves + // Clear overlay canvases when mouse leaves this.canvasInstance.canvasRenderer.clearOverlay(); + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); } handleMouseEnter(): void { @@ -919,11 +1008,18 @@ export class MaskTool { if (!this.isActive) return; if (this.isDrawing) { this.isDrawing = false; + + // Commit the stroke from overlay to actual mask chunks + this.commitStrokeToChunks(); + + // Clear stroke overlay and reset state + this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay(); + this.currentStrokePoints = []; this.lastPosition = null; this.currentDrawingChunk = null; // After drawing is complete, update active canvas to show all chunks - this.updateActiveMaskCanvas(true); // forceShowAll = true + this.updateActiveMaskCanvas(true); // Force full update this.completeMaskOperation(); this.drawBrushPreview(viewCoords); @@ -943,6 +1039,44 @@ export class MaskTool { this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); } + /** + * Commits the current stroke from overlay to actual mask chunks + * This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy + */ + private commitStrokeToChunks(): void { + if (this.currentStrokePoints.length < 2) { + return; // Need at least 2 points for a stroke + } + + log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`); + + // Replay the entire stroke path with interpolation for smooth, accurate lines + for (let i = 1; i < this.currentStrokePoints.length; i++) { + const startPoint = this.currentStrokePoints[i - 1]; + const endPoint = this.currentStrokePoints[i]; + + // Calculate distance between points + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < this.brushSize / 4) { + // Small distance - draw single segment + this.drawOnChunks(startPoint, endPoint); + } else { + // Large distance - interpolate for smooth line without gaps + const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance); + + // Draw all interpolated segments + for (let j = 0; j < interpolatedPoints.length - 1; j++) { + this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]); + } + } + } + + log.debug("Stroke committed to chunks successfully with interpolation"); + } + /** * Draws a line between two world coordinates on the appropriate chunks */ @@ -1040,29 +1174,17 @@ export class MaskTool { } /** - * Updates active canvas when drawing affects chunks with throttling to prevent lag - * During drawing, only updates the affected active chunks for performance + * Updates active canvas when drawing affects chunks + * Since we now use overlay during drawing, this is only called after drawing is complete */ private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void { - // Calculate which chunks were affected by this drawing operation - const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; - const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; - const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; - const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; - - const affectedChunkMinX = Math.floor(minX / this.chunkSize); - const affectedChunkMinY = Math.floor(minY / this.chunkSize); - const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); - const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); - - // During drawing, only update affected chunks that are active for performance - if (this.isDrawing) { - // Use throttled partial update for active chunks only - this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); - } else { + // This method is now simplified - we only update after drawing is complete + // The overlay handles all live preview, so we don't need complex chunk activation + if (!this.isDrawing) { // Not drawing - do full update to show all chunks this.updateActiveMaskCanvas(true); } + // During drawing, we don't update chunks at all - overlay handles preview } /**