Zooming makes an image editor usable. Exporting makes it trustworthy. The tricky part is making both happen at the same time.
That is the implementation problem behind tools such as Crop Image, Screenshot Redactor, and Document Redactor. Users need a zoomed, centered, comfortable editing viewport. The exported result still has to land on the original image pixels, not on whatever scale the screen happened to be showing.
Step 1: Treat the viewport as presentation, not truth
In this codebase, the Fabric canvas is sized to the visible container, then the viewport is transformed to fit the uploaded image.
const scale = Math.min(clientWidth / width, clientHeight / height) * 0.5;
const offsetX = (clientWidth - width * scale) / 2;
const offsetY = (clientHeight - height * scale) / 2;
this.canvas.setZoom(scale);
this.canvas.setViewportTransform([scale, 0, 0, scale, offsetX, offsetY]);That transform is important for editing comfort, but it should not become the coordinate system you store as your source of truth. The original image dimensions still define the real editing space.
Step 2: Keep objects in source-image coordinates
The base image and editable regions are inserted using the image's own width and height, not CSS pixels from the browser viewport.
this.image = new FabricImage(image, {
left: image.width / 2,
top: image.height / 2,
selectable: false,
evented: false,
});That means a crop box, blur patch, or redaction region still lives in image space even when the user is zoomed in or zoomed out. The viewport can change without invalidating the geometry.
Step 3: Do not commit scaled dimensions too early
One subtle bug in zoomed editors shows up during resize handles. If you keep rewriting width and height in the middle of every scaling event, the resize math starts compounding on itself.
The safer pattern in this project is to read the temporary size from width * scaleX and height * scaleY, but only commit the normalized geometry at the right moment.
const width = (this.moveableRect.width ?? 0) * (this.moveableRect.scaleX ?? 1);
const height = (this.moveableRect.height ?? 0) * (this.moveableRect.scaleY ?? 1);Then the editor can clamp the region back into the source image bounds and reset scaleX and scaleY to 1 once the interaction is complete.
That separation keeps the live interaction smooth without corrupting the stored geometry.
Step 4: Export from the source pixels again
This is the part many editors get wrong. Saving should not mean "capture whatever the viewport is currently showing." Saving should mean "rebuild the result from the original image coordinates."
For crop export, the implementation creates a fresh canvas sized to the normalized crop region and draws directly from the original image:
context.drawImage(
this.sourceImage,
Math.round(nextRect.left),
Math.round(nextRect.top),
width,
height,
0,
0,
width,
height
);For full markup export, the same idea shows up as a StaticCanvas at original size, with the base image re-added and overlay objects replayed onto it.
That is why the exported file stays aligned even if the on-screen editor was zoomed to 50 percent or 300 percent.
Step 5: Use viewport transforms for comfort only
The useful mental model is simple:
- viewport transform is for viewing
- object geometry is for editing
- export canvas is for truth
Once those responsibilities stay separate, the rest of the system becomes easier to reason about. Zooming no longer threatens export accuracy, and export no longer depends on whatever the browser viewport happened to look like at save time.
Where this matters most
This boundary becomes especially important in tools where precision matters:
- crop regions
- redaction boxes
- blur and pixelation patches
- auto-detected privacy regions
If those objects drift because the viewport and the source coordinates got mixed together, the output is wrong even if the on-screen editor looked fine.
That is why a serious image editor should never treat the zoomed viewport as the final artifact. The viewport is only the interaction surface. The original pixels are still the contract.
