Summary of Bugs
| # | Bug | Affected Tools | Symptom | Root Cause |
|---|---|---|---|---|
| Bug 1 | Wrong shape start coordinates | Line, Circle, Rectangle | Shape draws with near-zero size; appears as a dot or tiny mark | lastX/lastY overwritten by every mousemove — they held the drag end, not the start |
| Bug 2 | No live preview while dragging | Line, Circle, Rectangle | Canvas shows nothing while the user drags; shape only appears (briefly) on mouse release | draw() had empty comment placeholders — no preview rendering code was ever executed during mousemove |
| Bug 3 | Async canvas restore race condition | Line, Circle, Rectangle | Final shape flickers into existence then immediately disappears | stopDrawing() called redrawCanvas() (which uses img.onload — asynchronous), then drew the shape synchronously before the restore was complete; the onload callback fired later and wiped the shape |
All three bugs exist identically in both script.js (vanilla version) and jjSketchDrawBoostrap5Sketch.js (Bootstrap version). The zero-suffix files (script0.js, jjSketchDrawBoostrap5Sketch0.js) are preserved as-is for comparison.
🐛 Bug 1 — Wrong Shape Start Coordinates
Why it happened: The variables lastX and lastY serve a dual purpose in the drawing engine. For the pen tool they act as a "previous point" so each new segment connects to the last drawn point. However, draw() always updates them at the bottom of every mousemove callback:
lastX = x; lastY = y;
For shape tools (line, circle, rectangle), stopDrawing() was using lastX/lastY as the origin of the shape. Because the mouse had been moving since mousedown, by the time mouseup fired, lastX/lastY had been overwritten hundreds of times and now held the final mouse position — essentially the same point as currentX/currentY. The shape therefore had zero (or near-zero) dimensions.
Buggy Code (stopDrawing() in the zero files):
// lastX/lastY by this point = LAST mousemove position, NOT mousedown position
if (currentType === 'line') {
redrawCanvas();
ctx.moveTo(lastX, lastY); // ← WRONG: both ends are basically the same point
ctx.lineTo(currentX, currentY);
ctx.stroke();
}
Fix Applied:
Two new variables, startX and startY, are captured once in startDrawing() and are never modified during a drag. They always hold the mousedown anchor point.
// In startDrawing():
startX = lastX; // ← captured once at mousedown, never changed
startY = lastY;
// In stopDrawing():
if (currentType === 'line') {
ctx.putImageData(shapeSnapshot, 0, 0);
ctx.moveTo(startX, startY); // ← CORRECT anchor
ctx.lineTo(currentX, currentY);
ctx.stroke();
}
🐛 Bug 2 — No Live Preview While Dragging
Why it happened: The draw() function is called on every mousemove. For the pen and eraser tools it draws incrementally. For shape tools the original code contained only empty comment stubs:
Buggy Code (draw() in the zero files):
} else if (currentType === 'line') {
// Don't draw line in real-time, will be drawn on mouseup ← nothing happens
} else if (currentType === 'circle') {
// Preview circle ← nothing happens
} else if (currentType === 'rectangle') {
// Preview rectangle ← nothing happens
}
The Bootstrap zero file was slightly different but equally broken — shapes were assigned to stopDrawing() via a comment, with no code in draw().
Fix Applied:
A shapeSnapshot (ImageData) is captured synchronously via ctx.getImageData() at the moment of mousedown. On every mousemove, the snapshot is restored and the preview shape is redrawn on top, giving a smooth rubber-band preview.
// In startDrawing() — capture clean snapshot before any preview is drawn:
if (t === 'line' || t === 'circle' || t === 'rectangle') {
shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
// In draw() — restore snapshot then render preview on every mousemove:
} else if (currentType === 'line') {
ctx.putImageData(shapeSnapshot, 0, 0); // ← wipe preview from last frame
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(x, y);
ctx.stroke();
} else if (currentType === 'circle') {
ctx.putImageData(shapeSnapshot, 0, 0);
const radius = Math.sqrt(Math.pow(x - startX, 2) + Math.pow(y - startY, 2));
ctx.beginPath();
ctx.arc(startX, startY, radius, 0, 2 * Math.PI);
ctx.stroke();
} else if (currentType === 'rectangle') {
ctx.putImageData(shapeSnapshot, 0, 0);
ctx.strokeRect(startX, startY, x - startX, y - startY);
}
🐛 Bug 3 — Async Canvas Restore Race Condition
Why it happened: redrawCanvas() restores the canvas using an Image object and an onload callback — this is asynchronous. In stopDrawing(), the original code called redrawCanvas() and then immediately drew the final shape in the very next line:
Buggy Code (stopDrawing() in the zero files):
if (currentType === 'line') {
redrawCanvas(); // ← async: img.onload fires LATER
ctx.moveTo(lastX, lastY); // ← runs NOW, before restore is complete
ctx.lineTo(currentX, currentY);
ctx.stroke(); // ← shape drawn on the CURRENT dirty canvas
// ...then img.onload fires, clears canvas, shape is GONE
}
The execution order was: draw shape → (milliseconds later) restore canvas. The restore wiped the just-drawn shape. Even if Bugs 1 and 2 were somehow absent, Bug 3 alone would have made shapes disappear on release.
Fix Applied:
redrawCanvas() is replaced by ctx.putImageData(shapeSnapshot, 0, 0) for shape tools. putImageData() is synchronous — it completes before the next line runs, so the restore and the final draw always happen in the correct order.
if (currentType === 'line') {
ctx.putImageData(shapeSnapshot, 0, 0); // ← synchronous: completes immediately
ctx.strokeStyle = colorPicker.value;
ctx.lineWidth = brushSize.value;
ctx.beginPath();
ctx.moveTo(startX, startY); // ← correct anchor
ctx.lineTo(currentX, currentY);
ctx.stroke(); // ← shape stays on canvas ✓
}
shapeSnapshot = null; // release the snapshot
Why does redrawCanvas() use async at all? Because history[] stores canvas state as data URLs (strings, not ImageData). Loading a data URL back onto the canvas requires creating an Image object and waiting for it to decode — hence img.onload. The undo button still uses redrawCanvas() correctly because there is no synchronous drawing immediately after it. For the shape workflow, shapeSnapshot is an ImageData object (raw pixel bytes) so it can be restored instantly with putImageData().
📁 Files Changed
| File | Version | Changes |
|---|---|---|
script.js |
Vanilla / active | Added startX, startY, shapeSnapshot; updated startDrawing(), draw(), stopDrawing() |
jjSketchDrawBoostrap5Sketch.js |
Bootstrap 5 / active | Identical changes to the above |
script0.js |
Vanilla / zero-state | Unchanged — preserved as comparison reference |
jjSketchDrawBoostrap5Sketch0.js |
Bootstrap 5 / zero-state | Unchanged — preserved as comparison reference |
index.html |
Vanilla / active | No changes needed — already references script.js |
jjSketchDrawBootstrap5.html |
Bootstrap 5 / active | No changes needed — already references jjSketchDrawBoostrap5Sketch.js |
🎓 Teaching Notes
Bug 1 illustrates a classic mistake: using a variable for two different roles (current position and start position). When a variable's value changes continuously during an operation, you must capture a snapshot of the value you need to remember.
Lesson: If you need a value to stay fixed while other code runs, save it to a separate, dedicated variable.
Bug 2 demonstrates the standard pattern for drawing shape previews: save → clear → redraw on every frame. The key insight is that the canvas must be restored to a clean baseline before each preview frame, otherwise previews accumulate and corrupt the image.
Lesson: For animated/preview drawing, store a baseline snapshot and restore it at the start of every frame.
Bug 3 is a classic JavaScript asynchrony problem. img.onload is a callback — it runs later, not immediately. Any code placed after it still runs first. This surprises many beginners.
Lesson: ImageData (getImageData/putImageData) operates synchronously. Data URLs (toDataURL/img.src) require async decoding. Use the right tool for the timing you need.