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.
This commit is contained in:
Dariusz L
2025-08-08 17:13:44 +02:00
parent dd2a81b6f2
commit de83a884c2
8 changed files with 653 additions and 114 deletions

View File

@@ -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?.();
}

View File

@@ -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);

View File

@@ -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", {

View File

@@ -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<string, MaskChunk>; // 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
}
/**