fix: canvas fits image exactly, chained annotation loading, arrow move tracking

- Canvas now sizes to match the scaled image dimensions (zero empty space)
  instead of using a fixed aspect ratio, fixing coordinate mismatch between
  editor and viewer
- Annotations load only after background image is ready via _pendingAnnotations
  pattern, preventing placement at wrong coordinates
- Arrow endpoints (arrowX1/Y1/X2/Y2) update on object:modified using
  transform delta, so moved arrows serialize at correct position
- Coordinate scaling on load: coordScale = currentImageScale / savedImageScale
  handles annotations saved at different screen widths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-08 16:10:21 +01:00
parent f2df6be060
commit 9e1bb20b36
6 changed files with 336 additions and 85 deletions
+252 -73
View File
@@ -64,6 +64,13 @@ function annotationEditor() {
/** Whether the canvas has unsaved changes */
isDirty: false,
/** Natural (unscaled) dimensions of the loaded background image */
_bgImageNatW: 0,
_bgImageNatH: 0,
/** Annotations JSON to load after background image is ready */
_pendingAnnotations: null,
/** Current color for new annotations */
currentColor: '#2563EB',
@@ -93,17 +100,118 @@ function annotationEditor() {
return;
}
// Objects are move-only: no resize/rotate handles, just selection border
fabric.Object.prototype.hasControls = false;
fabric.Object.prototype.hasBorders = true;
fabric.Object.prototype.borderColor = '#2563EB';
fabric.Object.prototype.lockRotation = true;
fabric.Object.prototype.lockScalingX = true;
fabric.Object.prototype.lockScalingY = true;
// Disable object caching to avoid stale render states
fabric.Object.prototype.objectCaching = false;
try {
this.canvas = new fabric.Canvas(this.canvasEl, {
selection: true,
preserveObjectStacking: true,
backgroundColor: '#f1f5f9',
enableRetinaScaling: false,
});
} catch (err) {
console.error('AnnotationEditor: failed to create canvas:', err);
return;
}
var self = this;
var canvas = this.canvas;
// ---- Fabric.js v5 controls-visibility workaround ----
//
// Fabric v5.3.1 draws object controls (resize/rotate handles) on the
// LOWER canvas. The UPPER canvas (contextTop) sits on top and can
// obscure those controls if it has stale content.
// Fix: force contextTopDirty = true before every renderAll().
var _origRenderAll = canvas.renderAll.bind(canvas);
canvas.renderAll = function () {
canvas.contextTopDirty = true;
return _origRenderAll();
};
// ---- Reliable pointer override ----
//
// Fabric.js v5.3.1 getPointer can produce wrong coordinates due to
// retina-scaling arithmetic, stale _offset cache, or scroll-offset
// mismatches. Replace with a minimal BCR-based calculation that is
// always correct: mouse-client-position minus canvas-BCR, scaled to
// canvas coordinate space.
canvas.getPointer = function (e, ignoreVpt) {
// Honour Fabric's per-event cache (set by _cacheTransformEventData)
if (canvas._absolutePointer && !ignoreVpt) {
return canvas._absolutePointer;
}
if (canvas._pointer && ignoreVpt) {
return canvas._pointer;
}
// Extract clientX/Y from mouse or touch event
var clientX = 0, clientY = 0;
if (e) {
if (e.clientX != null) {
clientX = e.clientX;
clientY = e.clientY;
} else if (e.changedTouches && e.changedTouches[0]) {
clientX = e.changedTouches[0].clientX;
clientY = e.changedTouches[0].clientY;
} else if (e.touches && e.touches[0]) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
}
}
// BCR of the upper canvas (where events are captured)
var rect = canvas.upperCanvasEl.getBoundingClientRect();
// Position relative to canvas element in CSS pixels
var x = clientX - rect.left;
var y = clientY - rect.top;
// Scale from CSS pixels to canvas internal coordinate space
// With enableRetinaScaling:false these should be 1:1, but handle
// any mismatch defensively.
if (rect.width > 0 && rect.height > 0) {
x = x * (canvas.width / rect.width);
y = y * (canvas.height / rect.height);
}
// When ignoreVpt is false, return object-space coordinates
if (!ignoreVpt) {
return canvas.restorePointerVpt({ x: x, y: y });
}
return { x: x, y: y };
};
// ---- Keep canvas offset fresh ----
canvas.on('mouse:down:before', function () {
canvas.calcOffset();
});
// Force move-only properties on every object added to the canvas
this.canvas.on('object:added', function (e) {
var obj = e.target;
if (obj && obj.objectType !== '_temp') {
obj.hasControls = false;
obj.hasBorders = true;
obj.selectable = true;
obj.evented = true;
obj.lockRotation = true;
obj.lockScalingX = true;
obj.lockScalingY = true;
obj.setCoords();
}
});
this.setupEvents();
this.setupKeyboard();
@@ -111,7 +219,6 @@ function annotationEditor() {
window.addEventListener('resize', this._debouncedResize);
// ---- Bridge: listen for commands from recipeEditor ----
var self = this;
this._onToolChange = function (e) {
if (e.detail && e.detail.tool) {
@@ -125,6 +232,11 @@ function annotationEditor() {
};
window.addEventListener('anno-delete-selected', this._onDeleteSelected);
this._onClearAll = function () {
self.clearAll();
};
window.addEventListener('anno-clear-all', this._onClearAll);
this._onColorChange = function (e) {
if (e.detail && e.detail.color) {
self.currentColor = e.detail.color;
@@ -253,12 +365,12 @@ function annotationEditor() {
/**
* Internal: load background image and optionally annotations.
* Annotations are deferred until the image callback fires so that
* canvas dimensions and imageScale are known before placing objects.
*/
_loadImageAndAnnotations(imageUrl, annotationsJson) {
this._pendingAnnotations = annotationsJson || null;
this.loadBackgroundImage(imageUrl);
if (annotationsJson) {
this.loadAnnotationsJson(annotationsJson);
}
},
// ----------------------------------------------------------------
@@ -325,6 +437,7 @@ function annotationEditor() {
/**
* Load a raster image URL as the canvas background.
* The canvas is resized to fit the image exactly (no empty space).
*/
_loadImageBackground(imageUrl) {
var self = this;
@@ -337,35 +450,64 @@ function annotationEditor() {
return;
}
if (!self.canvas.width) {
self.resizeCanvas();
}
var cw = self.canvas.width || 800;
var ch = self.canvas.height || 480;
var scale = Math.min(
cw / img.width,
ch / img.height,
1 // never upscale
);
self.canvas.setBackgroundImage(
img,
self.canvas.renderAll.bind(self.canvas),
{
scaleX: scale,
scaleY: scale,
originX: 'left',
originY: 'top',
}
);
self._bgImageNatW = img.width;
self._bgImageNatH = img.height;
self._fitCanvasToImage(img);
self.imageLoaded = true;
// Load pending annotations now that canvas is sized to the image
if (self._pendingAnnotations) {
self.loadAnnotationsJson(self._pendingAnnotations);
self._pendingAnnotations = null;
}
}
);
},
/**
* Resize canvas to fit the background image exactly within the
* container width. Canvas dimensions = image dimensions * scale,
* so there is zero empty space and canvas coords map 1:1 to
* scaled-image coords.
*
* @param {fabric.Image} img - Fabric image object (background)
*/
_fitCanvasToImage(img) {
var fabricWrapper = this.canvasEl.parentElement;
var container = fabricWrapper ? fabricWrapper.parentElement : null;
if (!container) return;
var containerWidth = container.clientWidth;
if (!containerWidth) return;
var natW = img.width || this._bgImageNatW;
var natH = img.height || this._bgImageNatH;
if (!natW || !natH) return;
// Scale to fit container width; never upscale
var scale = Math.min(containerWidth / natW, 1);
var canvasW = Math.round(natW * scale);
var canvasH = Math.round(natH * scale);
this.canvas.setDimensions({ width: canvasW, height: canvasH });
this.canvas.setBackgroundImage(
img,
this.canvas.renderAll.bind(this.canvas),
{
scaleX: scale,
scaleY: scale,
originX: 'left',
originY: 'top',
}
);
this.canvas.calcOffset();
this.canvas.forEachObject(function (obj) { obj.setCoords(); });
this.canvas.renderAll();
},
// ----------------------------------------------------------------
// Object creation - Markers
// ----------------------------------------------------------------
@@ -403,7 +545,12 @@ function annotationEditor() {
left: x,
top: y,
selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
// Custom properties
objectType: 'marker',
markerNumber: markerNumber,
@@ -411,6 +558,7 @@ function annotationEditor() {
});
this.canvas.add(marker);
marker.setCoords();
this.canvas.setActiveObject(marker);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -454,6 +602,12 @@ function annotationEditor() {
var arrow = new fabric.Group([line, arrowHead], {
selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
objectType: 'arrow',
// Store original endpoints for serialization
arrowX1: x1,
@@ -466,6 +620,7 @@ function annotationEditor() {
});
this.canvas.add(arrow);
arrow.setCoords();
this.canvas.setActiveObject(arrow);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -496,6 +651,12 @@ function annotationEditor() {
strokeWidth: this.currentStrokeWidth,
strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
objectType: 'area',
areaColor: this.currentColor,
areaStrokeWidth: this.currentStrokeWidth,
@@ -503,6 +664,7 @@ function annotationEditor() {
});
this.canvas.add(rect);
rect.setCoords();
this.canvas.setActiveObject(rect);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -527,8 +689,6 @@ function annotationEditor() {
this.canvas.forEachObject(function (o) {
o.selectable = true;
o.evented = true;
o.hasControls = true;
o.hasBorders = true;
});
} else {
// marker, arrow, rect — disable object interaction during drawing
@@ -562,6 +722,9 @@ function annotationEditor() {
// ---- mouse:down ----
this.canvas.on('mouse:down', function (opt) {
// In select mode, let Fabric.js handle clicks without interference
if (self.activeMode === 'select') return;
var pointer = self.canvas.getPointer(opt.e);
if (self.activeMode === 'marker') {
@@ -677,6 +840,12 @@ function annotationEditor() {
detail: { markerNumber: selected[0].markerNumber }
}));
}
// Force coords + re-render so controls are visible on lower canvas
var active = self.canvas.getActiveObject();
if (active) {
active.setCoords();
self.canvas.requestRenderAll();
}
});
this.canvas.on('selection:updated', function (e) {
@@ -689,6 +858,12 @@ function annotationEditor() {
detail: { markerNumber: selected[0].markerNumber }
}));
}
// Force coords + re-render so controls are visible on lower canvas
var active = self.canvas.getActiveObject();
if (active) {
active.setCoords();
self.canvas.requestRenderAll();
}
});
this.canvas.on('selection:cleared', function () {
@@ -698,12 +873,18 @@ function annotationEditor() {
// ---- Object modification ----
this.canvas.on('object:modified', function () {
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
this.canvas.on('object:moved', function () {
this.canvas.on('object:modified', function (e) {
// Update arrow stored endpoints after a move so serialization
// reflects the current position (not the original creation coords).
var obj = e.target;
if (obj && obj.objectType === 'arrow' && e.transform) {
var dx = obj.left - e.transform.original.left;
var dy = obj.top - e.transform.original.top;
obj.arrowX1 = (obj.arrowX1 || 0) + dx;
obj.arrowY1 = (obj.arrowY1 || 0) + dy;
obj.arrowX2 = (obj.arrowX2 || 0) + dx;
obj.arrowY2 = (obj.arrowY2 || 0) + dy;
}
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
@@ -794,7 +975,13 @@ function annotationEditor() {
resizeCanvas() {
if (!this.canvasEl || !this.canvas) return;
// Fabric wrapper → .canvas-container (or whatever holds the canvas)
// If a background image is loaded, fit canvas to its dimensions
if (this.canvas.backgroundImage && this._bgImageNatW && this._bgImageNatH) {
this._fitCanvasToImage(this.canvas.backgroundImage);
return;
}
// No image yet — use placeholder size so canvas is visible
var fabricWrapper = this.canvasEl.parentElement;
var container = fabricWrapper ? fabricWrapper.parentElement : null;
if (!container) return;
@@ -802,8 +989,7 @@ function annotationEditor() {
var width = container.clientWidth;
var height = Math.max(400, Math.round(width * 0.6));
this.canvas.setWidth(width);
this.canvas.setHeight(height);
this.canvas.setDimensions({ width: width, height: height });
this.canvas.calcOffset();
this.canvas.renderAll();
},
@@ -825,9 +1011,6 @@ function annotationEditor() {
top: Math.round(obj.top * 100) / 100,
width: Math.round((obj.width || 0) * 100) / 100,
height: Math.round((obj.height || 0) * 100) / 100,
scaleX: Math.round((obj.scaleX || 1) * 1000) / 1000,
scaleY: Math.round((obj.scaleY || 1) * 1000) / 1000,
angle: Math.round((obj.angle || 0) * 100) / 100,
markerNumber: obj.markerNumber || null,
};
@@ -851,10 +1034,16 @@ function annotationEditor() {
return data;
});
// Save background image scale so the preview can convert canvas coords
// to image coords correctly regardless of editor canvas dimensions.
var bgImg = this.canvas.backgroundImage;
var imageScale = bgImg ? (bgImg.scaleX || 1) : 1;
return JSON.stringify({
version: 1,
version: 2,
width: this.canvas.width,
height: this.canvas.height,
imageScale: imageScale,
objects: objects,
});
},
@@ -876,6 +1065,15 @@ function annotationEditor() {
var data =
typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;
// Coordinate scale factor: annotations may have been saved at a
// different canvas size (different screen width). Since canvas
// coords = imageCoords * imageScale, we convert:
// currentCoord = savedCoord * (currentImageScale / savedImageScale)
var savedImageScale = data.imageScale || 1;
var bgImg = this.canvas.backgroundImage;
var currentImageScale = bgImg ? (bgImg.scaleX || 1) : 1;
var cs = currentImageScale / savedImageScale; // coordinate scale
// Remove existing objects
this.canvas.getObjects().slice().forEach(
function (obj) {
@@ -891,15 +1089,8 @@ function annotationEditor() {
if (obj.type === 'marker') {
var savedColor = this.currentColor;
if (obj.fill) this.currentColor = obj.fill;
var created = this.addMarker(obj.left, obj.top, obj.markerNumber);
this.addMarker(obj.left * cs, obj.top * cs, obj.markerNumber);
this.currentColor = savedColor;
// Restore transforms (scale + rotation)
created.set({
angle: obj.angle || 0,
scaleX: obj.scaleX || 1,
scaleY: obj.scaleY || 1,
});
created.setCoords();
} else if (obj.type === 'arrow') {
var savedColor2 = this.currentColor;
var savedWidth = this.currentStrokeWidth;
@@ -908,24 +1099,15 @@ function annotationEditor() {
if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
if (obj.lineDash) this.currentLineDash = obj.lineDash;
else this.currentLineDash = [];
// Reconstruct arrow from stored endpoints or from position/size
var x1 = obj.x1 != null ? obj.x1 : obj.left;
var y1 = obj.y1 != null ? obj.y1 : obj.top;
var x2 = obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100);
var y2 = obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50);
var created2 = this.addArrow(x1, y1, x2, y2);
// Reconstruct arrow from stored endpoints, scaled to current canvas
var x1 = (obj.x1 != null ? obj.x1 : obj.left) * cs;
var y1 = (obj.y1 != null ? obj.y1 : obj.top) * cs;
var x2 = (obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100)) * cs;
var y2 = (obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50)) * cs;
this.addArrow(x1, y1, x2, y2);
this.currentColor = savedColor2;
this.currentStrokeWidth = savedWidth;
this.currentLineDash = savedDash;
// Restore transforms (position + scale + rotation)
created2.set({
left: obj.left,
top: obj.top,
angle: obj.angle || 0,
scaleX: obj.scaleX || 1,
scaleY: obj.scaleY || 1,
});
created2.setCoords();
} else if (obj.type === 'area') {
var savedColor3 = this.currentColor;
var savedWidth2 = this.currentStrokeWidth;
@@ -934,17 +1116,10 @@ function annotationEditor() {
if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
if (obj.lineDash) this.currentLineDash = obj.lineDash;
else this.currentLineDash = [];
var created3 = this.addRect(obj.left, obj.top, obj.width || 150, obj.height || 100);
this.addRect(obj.left * cs, obj.top * cs, (obj.width || 150) * cs, (obj.height || 100) * cs);
this.currentColor = savedColor3;
this.currentStrokeWidth = savedWidth2;
this.currentLineDash = savedDash2;
// Restore transforms (scale + rotation)
created3.set({
angle: obj.angle || 0,
scaleX: obj.scaleX || 1,
scaleY: obj.scaleY || 1,
});
created3.setCoords();
}
}
@@ -961,6 +1136,7 @@ function annotationEditor() {
markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1;
this.isDirty = false;
this.canvas.discardActiveObject();
this.canvas.renderAll();
} catch (e) {
console.error('AnnotationEditor: failed to load annotations:', e);
@@ -1032,6 +1208,9 @@ function annotationEditor() {
if (this._onDeleteSelected) {
window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
}
if (this._onClearAll) {
window.removeEventListener('anno-clear-all', this._onClearAll);
}
if (this._onColorChange) {
window.removeEventListener('anno-color-change', this._onColorChange);
}