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 */ /** Whether the canvas has unsaved changes */
isDirty: false, 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 */ /** Current color for new annotations */
currentColor: '#2563EB', currentColor: '#2563EB',
@@ -93,17 +100,118 @@ function annotationEditor() {
return; 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 { try {
this.canvas = new fabric.Canvas(this.canvasEl, { this.canvas = new fabric.Canvas(this.canvasEl, {
selection: true, selection: true,
preserveObjectStacking: true, preserveObjectStacking: true,
backgroundColor: '#f1f5f9', backgroundColor: '#f1f5f9',
enableRetinaScaling: false,
}); });
} catch (err) { } catch (err) {
console.error('AnnotationEditor: failed to create canvas:', err); console.error('AnnotationEditor: failed to create canvas:', err);
return; 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.setupEvents();
this.setupKeyboard(); this.setupKeyboard();
@@ -111,7 +219,6 @@ function annotationEditor() {
window.addEventListener('resize', this._debouncedResize); window.addEventListener('resize', this._debouncedResize);
// ---- Bridge: listen for commands from recipeEditor ---- // ---- Bridge: listen for commands from recipeEditor ----
var self = this;
this._onToolChange = function (e) { this._onToolChange = function (e) {
if (e.detail && e.detail.tool) { if (e.detail && e.detail.tool) {
@@ -125,6 +232,11 @@ function annotationEditor() {
}; };
window.addEventListener('anno-delete-selected', this._onDeleteSelected); window.addEventListener('anno-delete-selected', this._onDeleteSelected);
this._onClearAll = function () {
self.clearAll();
};
window.addEventListener('anno-clear-all', this._onClearAll);
this._onColorChange = function (e) { this._onColorChange = function (e) {
if (e.detail && e.detail.color) { if (e.detail && e.detail.color) {
self.currentColor = e.detail.color; self.currentColor = e.detail.color;
@@ -253,12 +365,12 @@ function annotationEditor() {
/** /**
* Internal: load background image and optionally annotations. * 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) { _loadImageAndAnnotations(imageUrl, annotationsJson) {
this._pendingAnnotations = annotationsJson || null;
this.loadBackgroundImage(imageUrl); this.loadBackgroundImage(imageUrl);
if (annotationsJson) {
this.loadAnnotationsJson(annotationsJson);
}
}, },
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -325,6 +437,7 @@ function annotationEditor() {
/** /**
* Load a raster image URL as the canvas background. * Load a raster image URL as the canvas background.
* The canvas is resized to fit the image exactly (no empty space).
*/ */
_loadImageBackground(imageUrl) { _loadImageBackground(imageUrl) {
var self = this; var self = this;
@@ -337,35 +450,64 @@ function annotationEditor() {
return; return;
} }
if (!self.canvas.width) { self._bgImageNatW = img.width;
self.resizeCanvas(); self._bgImageNatH = img.height;
}
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._fitCanvasToImage(img);
self.imageLoaded = true; 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 // Object creation - Markers
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -403,7 +545,12 @@ function annotationEditor() {
left: x, left: x,
top: y, top: y,
selectable: true, selectable: true,
evented: true,
hasControls: false,
hasBorders: true, hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
// Custom properties // Custom properties
objectType: 'marker', objectType: 'marker',
markerNumber: markerNumber, markerNumber: markerNumber,
@@ -411,6 +558,7 @@ function annotationEditor() {
}); });
this.canvas.add(marker); this.canvas.add(marker);
marker.setCoords();
this.canvas.setActiveObject(marker); this.canvas.setActiveObject(marker);
this.isDirty = true; this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -454,6 +602,12 @@ function annotationEditor() {
var arrow = new fabric.Group([line, arrowHead], { var arrow = new fabric.Group([line, arrowHead], {
selectable: true, selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
objectType: 'arrow', objectType: 'arrow',
// Store original endpoints for serialization // Store original endpoints for serialization
arrowX1: x1, arrowX1: x1,
@@ -466,6 +620,7 @@ function annotationEditor() {
}); });
this.canvas.add(arrow); this.canvas.add(arrow);
arrow.setCoords();
this.canvas.setActiveObject(arrow); this.canvas.setActiveObject(arrow);
this.isDirty = true; this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -496,6 +651,12 @@ function annotationEditor() {
strokeWidth: this.currentStrokeWidth, strokeWidth: this.currentStrokeWidth,
strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null, strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
selectable: true, selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
objectType: 'area', objectType: 'area',
areaColor: this.currentColor, areaColor: this.currentColor,
areaStrokeWidth: this.currentStrokeWidth, areaStrokeWidth: this.currentStrokeWidth,
@@ -503,6 +664,7 @@ function annotationEditor() {
}); });
this.canvas.add(rect); this.canvas.add(rect);
rect.setCoords();
this.canvas.setActiveObject(rect); this.canvas.setActiveObject(rect);
this.isDirty = true; this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
@@ -527,8 +689,6 @@ function annotationEditor() {
this.canvas.forEachObject(function (o) { this.canvas.forEachObject(function (o) {
o.selectable = true; o.selectable = true;
o.evented = true; o.evented = true;
o.hasControls = true;
o.hasBorders = true;
}); });
} else { } else {
// marker, arrow, rect — disable object interaction during drawing // marker, arrow, rect — disable object interaction during drawing
@@ -562,6 +722,9 @@ function annotationEditor() {
// ---- mouse:down ---- // ---- mouse:down ----
this.canvas.on('mouse:down', function (opt) { 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); var pointer = self.canvas.getPointer(opt.e);
if (self.activeMode === 'marker') { if (self.activeMode === 'marker') {
@@ -677,6 +840,12 @@ function annotationEditor() {
detail: { markerNumber: selected[0].markerNumber } 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) { this.canvas.on('selection:updated', function (e) {
@@ -689,6 +858,12 @@ function annotationEditor() {
detail: { markerNumber: selected[0].markerNumber } 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 () { this.canvas.on('selection:cleared', function () {
@@ -698,12 +873,18 @@ function annotationEditor() {
// ---- Object modification ---- // ---- Object modification ----
this.canvas.on('object:modified', function () { this.canvas.on('object:modified', function (e) {
self.isDirty = true; // Update arrow stored endpoints after a move so serialization
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); // reflects the current position (not the original creation coords).
}); var obj = e.target;
if (obj && obj.objectType === 'arrow' && e.transform) {
this.canvas.on('object:moved', function () { 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; self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
}); });
@@ -794,7 +975,13 @@ function annotationEditor() {
resizeCanvas() { resizeCanvas() {
if (!this.canvasEl || !this.canvas) return; 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 fabricWrapper = this.canvasEl.parentElement;
var container = fabricWrapper ? fabricWrapper.parentElement : null; var container = fabricWrapper ? fabricWrapper.parentElement : null;
if (!container) return; if (!container) return;
@@ -802,8 +989,7 @@ function annotationEditor() {
var width = container.clientWidth; var width = container.clientWidth;
var height = Math.max(400, Math.round(width * 0.6)); var height = Math.max(400, Math.round(width * 0.6));
this.canvas.setWidth(width); this.canvas.setDimensions({ width: width, height: height });
this.canvas.setHeight(height);
this.canvas.calcOffset(); this.canvas.calcOffset();
this.canvas.renderAll(); this.canvas.renderAll();
}, },
@@ -825,9 +1011,6 @@ function annotationEditor() {
top: Math.round(obj.top * 100) / 100, top: Math.round(obj.top * 100) / 100,
width: Math.round((obj.width || 0) * 100) / 100, width: Math.round((obj.width || 0) * 100) / 100,
height: Math.round((obj.height || 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, markerNumber: obj.markerNumber || null,
}; };
@@ -851,10 +1034,16 @@ function annotationEditor() {
return data; 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({ return JSON.stringify({
version: 1, version: 2,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
imageScale: imageScale,
objects: objects, objects: objects,
}); });
}, },
@@ -876,6 +1065,15 @@ function annotationEditor() {
var data = var data =
typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString; 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 // Remove existing objects
this.canvas.getObjects().slice().forEach( this.canvas.getObjects().slice().forEach(
function (obj) { function (obj) {
@@ -891,15 +1089,8 @@ function annotationEditor() {
if (obj.type === 'marker') { if (obj.type === 'marker') {
var savedColor = this.currentColor; var savedColor = this.currentColor;
if (obj.fill) this.currentColor = obj.fill; 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; 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') { } else if (obj.type === 'arrow') {
var savedColor2 = this.currentColor; var savedColor2 = this.currentColor;
var savedWidth = this.currentStrokeWidth; var savedWidth = this.currentStrokeWidth;
@@ -908,24 +1099,15 @@ function annotationEditor() {
if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth; if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
if (obj.lineDash) this.currentLineDash = obj.lineDash; if (obj.lineDash) this.currentLineDash = obj.lineDash;
else this.currentLineDash = []; else this.currentLineDash = [];
// Reconstruct arrow from stored endpoints or from position/size // Reconstruct arrow from stored endpoints, scaled to current canvas
var x1 = obj.x1 != null ? obj.x1 : obj.left; var x1 = (obj.x1 != null ? obj.x1 : obj.left) * cs;
var y1 = obj.y1 != null ? obj.y1 : obj.top; var y1 = (obj.y1 != null ? obj.y1 : obj.top) * cs;
var x2 = obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100); 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); var y2 = (obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50)) * cs;
var created2 = this.addArrow(x1, y1, x2, y2); this.addArrow(x1, y1, x2, y2);
this.currentColor = savedColor2; this.currentColor = savedColor2;
this.currentStrokeWidth = savedWidth; this.currentStrokeWidth = savedWidth;
this.currentLineDash = savedDash; 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') { } else if (obj.type === 'area') {
var savedColor3 = this.currentColor; var savedColor3 = this.currentColor;
var savedWidth2 = this.currentStrokeWidth; var savedWidth2 = this.currentStrokeWidth;
@@ -934,17 +1116,10 @@ function annotationEditor() {
if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth; if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
if (obj.lineDash) this.currentLineDash = obj.lineDash; if (obj.lineDash) this.currentLineDash = obj.lineDash;
else this.currentLineDash = []; 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.currentColor = savedColor3;
this.currentStrokeWidth = savedWidth2; this.currentStrokeWidth = savedWidth2;
this.currentLineDash = savedDash2; 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; markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1;
this.isDirty = false; this.isDirty = false;
this.canvas.discardActiveObject();
this.canvas.renderAll(); this.canvas.renderAll();
} catch (e) { } catch (e) {
console.error('AnnotationEditor: failed to load annotations:', e); console.error('AnnotationEditor: failed to load annotations:', e);
@@ -1032,6 +1208,9 @@ function annotationEditor() {
if (this._onDeleteSelected) { if (this._onDeleteSelected) {
window.removeEventListener('anno-delete-selected', this._onDeleteSelected); window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
} }
if (this._onClearAll) {
window.removeEventListener('anno-clear-all', this._onClearAll);
}
if (this._onColorChange) { if (this._onColorChange) {
window.removeEventListener('anno-color-change', this._onColorChange); window.removeEventListener('anno-color-change', this._onColorChange);
} }
+13 -2
View File
@@ -172,7 +172,13 @@ function annotationViewer() {
// Editor format (objects array from annotation-editor.js) // Editor format (objects array from annotation-editor.js)
if (this.annotations.objects) { 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); _drawEditorAnnotations(this.ctx, this.annotations, annoScale);
return; return;
} }
@@ -397,7 +403,12 @@ function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) {
ctx.drawImage(source, 0, 0, canvas.width, canvas.height); ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if (annotations && annotations.objects) { 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); _drawEditorAnnotations(ctx, annotations, annoScale);
} else if (annotations && annotations.markers) { } else if (annotations && annotations.markers) {
_drawLegacyAnnotations(ctx, annotations, scale); _drawLegacyAnnotations(ctx, annotations, scale);
+1 -1
View File
@@ -436,7 +436,7 @@
<script> <script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script> </script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=4"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script> <script>
/** /**
* Recipe Preview - Minimal Alpine.js component * Recipe Preview - Minimal Alpine.js component
+38 -7
View File
@@ -34,8 +34,9 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Canvas container */ /* Canvas container — use custom class name to avoid conflict with
.canvas-container { Fabric.js internal .canvas-container wrapper div */
.anno-canvas-wrap {
position: relative; position: relative;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -43,6 +44,10 @@
overflow: hidden; overflow: hidden;
min-height: 400px; min-height: 400px;
} }
.anno-canvas-wrap canvas {
display: block;
}
/* Fabric.js internal wrapper also needs display:block on canvases */
.canvas-container canvas { .canvas-container canvas {
display: block; display: block;
} }
@@ -141,7 +146,7 @@
<span x-text="saving ? window.__i18n_taskDrawing.saving : window.__i18n_taskDrawing.saveAnnotations"></span> <span x-text="saving ? window.__i18n_taskDrawing.saving : window.__i18n_taskDrawing.saveAnnotations"></span>
</button> </button>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}" <a :href="'{{ url_for('maker.task_editor', recipe_id=recipe.id) }}' + '?_=' + Date.now()"
class="btn btn-secondary gap-1.5"> class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12"/> <path stroke-linecap="round" stroke-linejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
@@ -245,7 +250,7 @@
<!-- Separator --> <!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div> <div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
<!-- Delete --> <!-- Delete selected -->
<button type="button" <button type="button"
class="anno-btn text-red-500 hover:text-red-600" class="anno-btn text-red-500 hover:text-red-600"
@click="deleteAnnotation()" @click="deleteAnnotation()"
@@ -255,7 +260,21 @@
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
<span class="hidden sm:inline">{{ _('Elimina') }}</span> {{ _('Elimina') }}
</button>
<!-- Clear All -->
<button type="button"
class="anno-btn text-red-500 hover:text-red-600"
@click="if (confirm('{{ _('Eliminare tutte le annotazioni?') }}')) clearAllAnnotations()"
title="{{ _('Cancella tutto') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 13h6m2 9H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Cancella tutto') }}
</button> </button>
</div> </div>
@@ -337,7 +356,7 @@
<div class="tmf-card"> <div class="tmf-card">
<div class="tmf-card-body p-0"> <div class="tmf-card-body p-0">
<div x-data="annotationEditor()" <div x-data="annotationEditor()"
class="canvas-container" class="anno-canvas-wrap"
id="annotationCanvasContainer"> id="annotationCanvasContainer">
<canvas id="annotationCanvas" x-ref="fabricCanvas"></canvas> <canvas id="annotationCanvas" x-ref="fabricCanvas"></canvas>
</div> </div>
@@ -353,7 +372,7 @@
<script> <script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script> </script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=10"></script> <script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=25"></script>
<script> <script>
function taskDrawing() { function taskDrawing() {
const taskData = window.__taskData || {}; const taskData = window.__taskData || {};
@@ -459,6 +478,14 @@ function taskDrawing() {
this.errorMessage = data.detail || window.__i18n_taskDrawing.saveError; this.errorMessage = data.detail || window.__i18n_taskDrawing.saveError;
} else { } else {
this.successMessage = window.__i18n_taskDrawing.saveSuccess; this.successMessage = window.__i18n_taskDrawing.saveSuccess;
// Store updated annotations in sessionStorage so task_editor
// can pick them up and refresh the preview immediately.
try {
sessionStorage.setItem('taskDrawingUpdated', JSON.stringify({
taskId: this.taskId,
annotations_json: this.annotationsJson
}));
} catch (_e) { /* sessionStorage may be unavailable */ }
} }
} catch (err) { } catch (err) {
console.error('saveAnnotations error:', err); console.error('saveAnnotations error:', err);
@@ -480,6 +507,10 @@ function taskDrawing() {
window.dispatchEvent(new CustomEvent('anno-delete-selected')); window.dispatchEvent(new CustomEvent('anno-delete-selected'));
}, },
clearAllAnnotations() {
window.dispatchEvent(new CustomEvent('anno-clear-all'));
},
setAnnoColor(color) { setAnnoColor(color) {
this.annoColor = color; this.annoColor = color;
window.dispatchEvent(new CustomEvent('anno-color-change', { window.dispatchEvent(new CustomEvent('anno-color-change', {
+31 -1
View File
@@ -1075,7 +1075,14 @@
{% block extra_js %} {% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script> <script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=4"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script>
// Force reload when page is restored from browser bfcache (back/forward navigation).
// This ensures fresh data is displayed after editing annotations in task_drawing.
window.addEventListener('pageshow', function (event) {
if (event.persisted) { window.location.reload(); }
});
</script>
<script> <script>
function taskEditor() { function taskEditor() {
return { return {
@@ -1126,6 +1133,29 @@ function taskEditor() {
// Sort tasks by order_index on load // Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
// Check if returning from task_drawing with updated annotations
try {
var updatedRaw = sessionStorage.getItem('taskDrawingUpdated');
if (updatedRaw) {
sessionStorage.removeItem('taskDrawingUpdated');
var updated = JSON.parse(updatedRaw);
if (updated.taskId) {
var task = this.tasks.find(function(t) { return t.id === updated.taskId; });
if (task) {
// Merge updated annotations into local state
if (updated.annotations_json) {
task.annotations_json = (typeof updated.annotations_json === 'string')
? JSON.parse(updated.annotations_json) : updated.annotations_json;
}
// Auto-expand the edited task and render preview
this.expandedTask = task.id;
this.renderTaskPreview(task);
return; // skip default expansion logic
}
}
}
} catch (_e) { /* sessionStorage parse error - ignore */ }
// Auto-expand the first task if there is only one // Auto-expand the first task if there is only one
if (this.tasks.length === 1) { if (this.tasks.length === 1) {
this.expandedTask = this.tasks[0].id; this.expandedTask = this.tasks[0].id;
+1 -1
View File
@@ -521,7 +521,7 @@
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script> </script>
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script> <script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=4"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script> <script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
<script> <script>