[v2] Box drifting during resize & 1px jitter rendering (Math.round constraint)
Cropper version
Cropper.js v2.1.1
Link to minimal reproduction
https://fengyuanchen.github.io/cropperjs/playground.html
Steps to reproduce
For the 1px Jitter Bug:
- Open the playground in Firefox.
- Change your Browser Zoom (e.g.
CMD+-) to 90% or 110% (subpixel rendering). - Drag the
<cropper-selection>window around. - Watch the opposite edge of the selection box.
For the Box Drifting Bug:
- Open the playground.
- Click and hold the bottom-right resize handle of the selection.
- Move your mouse back and forth very fast continuously.
- Move your mouse exactly back to where you started holding it down.
- Watch the box footprint: It will have drifted significantly from its original bounding size.
What is expected?
- The selection edges should render smoothly without constant 1-pixel flickering.
- The box should maintain its exact calculated footprint based on the absolute mouse coordinates. If the mouse returns to the exact starting pixel, the box should be the exact same size as when the dragging started.
What is actually happening?
- 1px Jitter: Because
<cropper-selection>defaults toprecise = false,Math.round()is enforced on pointer coordinates. At subpixel zoom levels, forcing moving float coordinates into an integer grid causes the box edges to constantly flicker/stretch by 1px. - Box Drifting: The core resize algorithm appears to accumulate deltas during
pointermove(new_width = old_width + deltaX). Due to high firing rates and float inaccuracies, the deltas add up. This creates a continuous drifting effect where the box distorts itself the more you move the mouse.
System Info
System: macOS / Windows
Browsers: Firefox (for jitter), All browsers (for drifting)Any additional comments?
Possible solutions:
- For the jitter, injecting
this.cropperSelection.precise = true;resolves the rounding stutter. We propose exposingpreciseas a boolean HTML attribute for<cropper-selection>. - For the drift, we replaced the delta-accumulation loop with an Anchor-Based Calculation. On
pointerdown, we save the absolute coordinate of the stationary opposite edge (the anchor). Onpointermove, we calculate the new dimensions purely as the absolute difference between the current pointer and the static anchor. This makes it mathematically impossible to accumulate drifting errors.
Possible solutions we already implemented successfully in our environment:
1. For the jitter:
Injecting this.cropperSelection.precise = true; resolves the rounding stutter completely. We propose exposing precise as a boolean HTML attribute for <cropper-selection>.
// Initialization
const selection = this.cropper.getCropperSelection();
if (selection) {
selection.precise = true; // Force precision for pointer coordinates
}2. For the drift:
We replaced the delta-accumulation loop with an Anchor-Based Calculation. On pointerdown, we save the absolute coordinate of the stationary opposite edge (the anchor). On pointermove, we calculate the new dimensions purely as the absolute difference between the current pointer and the static anchor. This makes it mathematically impossible to accumulate drifting errors.
const handleSpecs = {
'n-resize': { axis: 'v', ax: 0.5, ay: 1 },
's-resize': { axis: 'v', ax: 0.5, ay: 0 },
'w-resize': { axis: 'h', ax: 1, ay: 0.5 },
'e-resize': { axis: 'h', ax: 0, ay: 0.5 },
'nw-resize': { axis: 'c', ax: 1, ay: 1 },
'ne-resize': { axis: 'c', ax: 0, ay: 1 },
'sw-resize': { axis: 'c', ax: 1, ay: 0 },
'se-resize': { axis: 'c', ax: 0, ay: 0 },
};
let drag = null;
// 1. Hook on pointerdown
cropperCanvas.addEventListener('pointerdown', (e) => {
const action = e.target.getAttribute('action');
const spec = handleSpecs[action];
if (!spec) return;
// Lock the opposite edge relative to the dragged handle as an unmoving anchor
drag = {
ratio: cropperSelection.aspectRatio,
spec,
anchorX: cropperSelection.x + spec.ax * cropperSelection.width,
anchorY: cropperSelection.y + spec.ay * cropperSelection.height,
};
}, true);
// 2. Hook on pointermove
window.addEventListener('pointermove', (e) => {
if (!drag) return;
const mx = e.clientX - canvasRect.left;
const my = e.clientY - canvasRect.top;
let newW, newH;
// 3. Re-calculate pure absolute difference (no deltas)
if (drag.spec.axis === 'c') {
const dx = Math.abs(mx - drag.anchorX);
const dy = Math.abs(my - drag.anchorY);
if (Number.isFinite(drag.ratio) && drag.ratio > 0) {
// Apply ratio from the anchor
if (dy === 0 || dx / Math.max(dy, 1) > drag.ratio) {
newW = dx;
newH = newW / drag.ratio;
} else {
newH = dy;
newW = newH * drag.ratio;
}
}
}
// 4. Send clean, non-drifted coordinates back to the selection
const newX = drag.anchorX - drag.spec.ax * newW;
const newY = drag.anchorY - drag.spec.ay * newH;
cropperSelection.$change(newX, newY, newW, newH, drag.ratio);
}, true);We would love to know if you are open to a PR with this approach before submitting one!
Thanks for this great script.
Our REDAXO CMS Addon context: https://github.com/FriendsOfREDAXO/cropper
Source: fengyuanchen/cropperjs