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 @@ - + - + - + + - +