This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
I am building a browser-based text removal workspace where a user uploads an image, paints over unwanted text or objects, and sends the resulting mask to an image-editing pipeline.
The mask editor uses three stacked <canvas> elements:
- a base canvas for the uploaded image;
- an overlay canvas for the painted mask;
- a cursor canvas for the brush preview and pointer events.
All three canvases must have identical dimensions. The pointer coordinates must also map back to the same bitmap coordinate system, or the generated mask will not match the part of the image the user selected.
Bug Fix
On desktop, the editor had plenty of horizontal space but the uploaded image appeared inside a narrow strip surrounded by a large empty area. The result preview used the available width correctly, so the two sides of the same workspace looked unrelated.
The visible symptom was a tiny image editor. The actual failure started before the image was drawn.
The initialization code measured the width of the canvas wrapper:
const container = canvas.parentElement
if (!container) return
const containerWidth = container.clientWidth || 1
const containerHeight = 600
Enter fullscreen mode Exit fullscreen mode
It then calculated the largest canvas size that would preserve the uploaded image's aspect ratio:
const imgAspectRatio = img.width / img.height
const containerAspectRatio = containerWidth / containerHeight
let canvasWidth: number
let canvasHeight: number
if (imgAspectRatio > containerAspectRatio) {
canvasWidth = containerWidth
canvasHeight = containerWidth / imgAspectRatio
} else {
canvasHeight = containerHeight
canvasWidth = containerHeight * imgAspectRatio
}
Enter fullscreen mode Exit fullscreen mode
The aspect-ratio calculation was correct. The measurement it received was not.
Root Cause: The Canvas Measured Itself
The wrapper was a relatively positioned element with no declared width:
<div
className="relative transition-all duration-500 ease-out"
style={{
opacity: imageLoaded ? 1 : 0,
scale: imageLoaded ? '1' : '0.98',
}}
>
<canvas ref={canvasRef} className="block" />
<canvas ref={maskCanvasRef} className="pointer-events-none absolute left-0 top-0" />
<canvas ref={cursorCanvasRef} className="absolute left-0 top-0 cursor-none" />
</div>
Enter fullscreen mode Exit fullscreen mode
Before initialization, an HTML canvas has an intrinsic default size of 300 × 150. Because the wrapper did not claim the available workspace width, its width collapsed around the in-flow base canvas.
The sequence became:
- The base canvas started at its intrinsic 300px width.
- The wrapper sized itself around that canvas.
- The initializer read the wrapper's
clientWidth. - The code received roughly 300px and treated it as the available editor width.
- All three canvases were deliberately resized to fit that incorrect measurement.
The code was not randomly producing a 300px editor. It was faithfully preserving the browser's default canvas width.
The Fix
The fix was one utility class:
- <div className="relative transition-all duration-500 ease-out">
+ <div className="relative w-full transition-all duration-500 ease-out">
Enter fullscreen mode Exit fullscreen mode
With w-full, the wrapper resolves against the real workspace width before initializeCanvas reads clientWidth. The existing aspect-ratio calculation can then do its intended job.
No hard-coded desktop width was needed. The containing layout remains responsible for the available space, while the canvas code remains responsible for fitting the image inside it.
Why I Did Not Set canvas.style.width = "100%"
There are two sizes involved in a canvas:
- the bitmap dimensions represented by
canvas.widthandcanvas.height; - the displayed CSS dimensions.
Stretching only the CSS box could make the editor look wider while leaving its internal bitmap and pointer coordinate system unchanged.
This editor calculates pointer positions using the ratio between the bitmap size and the rendered rectangle:
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
}
Enter fullscreen mode Exit fullscreen mode
The safer correction was to fix the parent measurement, then let the existing initialization update the bitmap and display dimensions for all three canvas layers together.
Verification
I checked the complete interaction rather than stopping at the visual change:
- the uploaded image now uses the available desktop workspace width;
- the image keeps its original aspect ratio;
- the base, mask, and cursor canvases remain aligned;
- brush strokes stay under the pointer;
- the generated binary mask is still resized to the original image dimensions;
- the editor remains usable at mobile widths;
- TypeScript checks, ESLint, and the production build pass.
The result preview and the editor now have a consistent visual scale, so users can accurately mark small text and objects instead of painting on a compressed version of the image.
What I Learned
A layout measurement is only as reliable as the element being measured.
When a component reads clientWidth during initialization, I now check three things first:
- What establishes the measured element's width?
- Does a replaced element such as
<canvas>or<img>contribute an intrinsic fallback size? - Am I fixing the layout constraint, or merely stretching the rendered output afterward?
This bug looked like a canvas rendering problem, but the drawing code was working correctly. The browser was giving the component exactly the width its layout had asked for.
Sometimes the most useful fix is one class name backed by a much better understanding of where the number came from.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.