diff --git a/client/static/js/annotation-editor.js b/client/static/js/annotation-editor.js
index 7d3baed..669fc86 100644
--- a/client/static/js/annotation-editor.js
+++ b/client/static/js/annotation-editor.js
@@ -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);
}
diff --git a/client/static/js/annotation-viewer.js b/client/static/js/annotation-viewer.js
index c7ea3b5..eff5877 100644
--- a/client/static/js/annotation-viewer.js
+++ b/client/static/js/annotation-viewer.js
@@ -172,7 +172,13 @@ function annotationViewer() {
// Editor format (objects array from annotation-editor.js)
if (this.annotations.objects) {
- var annoScale = this.canvas.width / (this.annotations.width || this.canvas.width);
+ var previewScale = this.scale;
+ var imgScale = this.annotations.imageScale;
+ // v2+: use imageScale for correct canvas→image→preview mapping
+ // v1 fallback: annoScale = previewWidth / editorWidth
+ var annoScale = imgScale
+ ? (previewScale / imgScale)
+ : (this.canvas.width / (this.annotations.width || this.canvas.width));
_drawEditorAnnotations(this.ctx, this.annotations, annoScale);
return;
}
@@ -397,7 +403,12 @@ function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) {
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if (annotations && annotations.objects) {
- var annoScale = canvas.width / (annotations.width || srcW);
+ var imgScale = annotations.imageScale;
+ // v2+: use imageScale for correct canvas→image→preview mapping
+ // v1 fallback: annoScale = previewWidth / editorWidth
+ var annoScale = imgScale
+ ? (scale / imgScale)
+ : (canvas.width / (annotations.width || srcW));
_drawEditorAnnotations(ctx, annotations, annoScale);
} else if (annotations && annotations.markers) {
_drawLegacyAnnotations(ctx, annotations, scale);
diff --git a/client/templates/maker/recipe_preview.html b/client/templates/maker/recipe_preview.html
index cfc7457..17a86ee 100644
--- a/client/templates/maker/recipe_preview.html
+++ b/client/templates/maker/recipe_preview.html
@@ -436,7 +436,7 @@
-
+
-
+
-
+
+
-
+