fix: file display, persistence, PDF support and save error handling
- Add file proxy route in maker blueprint (X-API-Key auth for browser requests) - Persist file_path/annotations_json to DB via RecipeCreate/RecipeUpdate schemas - Fix canvas sizing using grandparent container instead of Fabric.js wrapper div - Defer canvas init with requestAnimationFrame for x-show timing - Add PDF.js support in annotation-editor and annotation-viewer - Fix annotations_json double-serialization (parse string to object before send) - Handle FastAPI 422 validation error arrays in api_client and JS error display - Update template URLs to use /maker/api/files/ proxy path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,6 @@ function annotationEditor() {
|
||||
|
||||
this.setupEvents();
|
||||
this.setupKeyboard();
|
||||
this.resizeCanvas();
|
||||
|
||||
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
|
||||
window.addEventListener('resize', this._debouncedResize);
|
||||
@@ -126,26 +125,63 @@ function annotationEditor() {
|
||||
|
||||
this._onImageLoaded = function (e) {
|
||||
if (e.detail && e.detail.path) {
|
||||
// Wait for x-show transition to reveal the canvas
|
||||
setTimeout(function () {
|
||||
self.resizeCanvas();
|
||||
self.loadBackgroundImage('/api/files/' + e.detail.path);
|
||||
if (e.detail.annotations) {
|
||||
self.loadAnnotationsJson(e.detail.annotations);
|
||||
}
|
||||
}, 150);
|
||||
// Wait for x-show transition to reveal the canvas and DOM layout
|
||||
self._deferredLoad(
|
||||
'/maker/api/files/' + e.detail.path,
|
||||
e.detail.annotations || null
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('image-loaded', this._onImageLoaded);
|
||||
|
||||
// ---- Initial load: check parent scope for existing file ----
|
||||
// Deferred to requestAnimationFrame so the browser has laid out the
|
||||
// container (x-cloak removed, x-show applied) before we read dimensions.
|
||||
var filePath = this.currentFilePath; // from parent recipeEditor scope
|
||||
if (filePath) {
|
||||
var annoJson = this.annotationsJson; // from parent recipeEditor scope
|
||||
this.loadBackgroundImage('/api/files/' + filePath);
|
||||
if (annoJson) {
|
||||
this.loadAnnotationsJson(annoJson);
|
||||
this._deferredLoad(
|
||||
'/maker/api/files/' + filePath,
|
||||
this.annotationsJson || null
|
||||
);
|
||||
} else {
|
||||
// No file yet — still resize so canvas is ready when image is uploaded
|
||||
requestAnimationFrame(function () {
|
||||
self.resizeCanvas();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deferred load: waits for the DOM to be fully laid out (via
|
||||
* requestAnimationFrame) then resizes the canvas and loads the image.
|
||||
* Falls back to a second attempt after 300ms if the container still
|
||||
* has zero width (e.g. Alpine x-show transition not yet complete).
|
||||
*/
|
||||
_deferredLoad(imageUrl, annotationsJson) {
|
||||
var self = this;
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
self.resizeCanvas();
|
||||
|
||||
// If container is still hidden / zero-width, retry after transition
|
||||
if (!self.canvas.width) {
|
||||
setTimeout(function () {
|
||||
self.resizeCanvas();
|
||||
self._loadImageAndAnnotations(imageUrl, annotationsJson);
|
||||
}, 350);
|
||||
} else {
|
||||
self._loadImageAndAnnotations(imageUrl, annotationsJson);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Internal: load background image and optionally annotations.
|
||||
*/
|
||||
_loadImageAndAnnotations(imageUrl, annotationsJson) {
|
||||
this.loadBackgroundImage(imageUrl);
|
||||
if (annotationsJson) {
|
||||
this.loadAnnotationsJson(annotationsJson);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -154,32 +190,93 @@ function annotationEditor() {
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load an image as the canvas background.
|
||||
* Scales the image to fit within the canvas while maintaining aspect ratio
|
||||
* and never exceeding the original size (scale <= 1).
|
||||
* Load an image or PDF as the canvas background.
|
||||
* For PDFs, renders the first page via PDF.js then uses it as background.
|
||||
* Scales to fit within the canvas while maintaining aspect ratio.
|
||||
*
|
||||
* @param {string} imageUrl - URL of the image to load
|
||||
* @param {string} imageUrl - URL of the image or PDF to load
|
||||
*/
|
||||
loadBackgroundImage(imageUrl) {
|
||||
if (!this.canvas) return;
|
||||
|
||||
if (!this.canvas.width) {
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
|
||||
|
||||
if (isPdf && typeof pdfjsLib !== 'undefined') {
|
||||
this._loadPdfBackground(imageUrl);
|
||||
} else {
|
||||
this._loadImageBackground(imageUrl);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render first page of a PDF via PDF.js and set as canvas background.
|
||||
*/
|
||||
_loadPdfBackground(pdfUrl) {
|
||||
var self = this;
|
||||
var loadingTask = pdfjsLib.getDocument(pdfUrl);
|
||||
|
||||
loadingTask.promise.then(function (pdf) {
|
||||
return pdf.getPage(1);
|
||||
}).then(function (page) {
|
||||
// Render at 2x scale for crisp display
|
||||
var scale = 2;
|
||||
var viewport = page.getViewport({ scale: scale });
|
||||
|
||||
// Off-screen canvas for rendering
|
||||
var offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = viewport.width;
|
||||
offCanvas.height = viewport.height;
|
||||
var ctx = offCanvas.getContext('2d');
|
||||
|
||||
var renderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
page.render(renderContext).promise.then(function () {
|
||||
// Convert rendered PDF page to data URL, then load as Fabric image
|
||||
var dataUrl = offCanvas.toDataURL('image/png');
|
||||
self._loadImageBackground(dataUrl);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
console.error('AnnotationEditor: failed to load PDF:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a raster image URL as the canvas background.
|
||||
*/
|
||||
_loadImageBackground(imageUrl) {
|
||||
var self = this;
|
||||
|
||||
fabric.Image.fromURL(
|
||||
imageUrl,
|
||||
function (img) {
|
||||
if (!img || !img.width || !img.height) {
|
||||
console.error('AnnotationEditor: failed to load background image');
|
||||
console.error('AnnotationEditor: failed to load background image from', imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.canvas.width) {
|
||||
self.resizeCanvas();
|
||||
}
|
||||
|
||||
var cw = self.canvas.width || 800;
|
||||
var ch = self.canvas.height || 480;
|
||||
|
||||
var scale = Math.min(
|
||||
this.canvas.width / img.width,
|
||||
this.canvas.height / img.height,
|
||||
cw / img.width,
|
||||
ch / img.height,
|
||||
1 // never upscale
|
||||
);
|
||||
|
||||
this.canvas.setBackgroundImage(
|
||||
self.canvas.setBackgroundImage(
|
||||
img,
|
||||
this.canvas.renderAll.bind(this.canvas),
|
||||
self.canvas.renderAll.bind(self.canvas),
|
||||
{
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
@@ -188,9 +285,8 @@ function annotationEditor() {
|
||||
}
|
||||
);
|
||||
|
||||
this.imageLoaded = true;
|
||||
}.bind(this),
|
||||
{ crossOrigin: 'anonymous' }
|
||||
self.imageLoaded = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -670,16 +766,25 @@ function annotationEditor() {
|
||||
/**
|
||||
* Resize the Fabric.js canvas to fill its parent container.
|
||||
* Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
|
||||
*
|
||||
* Note: Fabric.js wraps the <canvas> in its own div, so parentElement
|
||||
* is the Fabric wrapper (which has a fixed pixel width from creation).
|
||||
* We need the .canvas-container grandparent for the real layout width.
|
||||
*/
|
||||
resizeCanvas() {
|
||||
var container = this.canvasEl ? this.canvasEl.parentElement : null;
|
||||
if (!container || !this.canvas) return;
|
||||
if (!this.canvasEl || !this.canvas) return;
|
||||
|
||||
// Fabric wrapper → .canvas-container (or whatever holds the canvas)
|
||||
var fabricWrapper = this.canvasEl.parentElement;
|
||||
var container = fabricWrapper ? fabricWrapper.parentElement : null;
|
||||
if (!container) return;
|
||||
|
||||
var width = container.clientWidth;
|
||||
var height = Math.max(400, Math.round(width * 0.6));
|
||||
|
||||
this.canvas.setWidth(width);
|
||||
this.canvas.setHeight(height);
|
||||
this.canvas.calcOffset();
|
||||
this.canvas.renderAll();
|
||||
},
|
||||
|
||||
|
||||
@@ -80,47 +80,90 @@ function annotationViewer() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Carica e renderizza l'immagine con scaling
|
||||
* Carica e renderizza l'immagine (o la prima pagina PDF) con scaling
|
||||
*/
|
||||
loadImage() {
|
||||
var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
|
||||
|
||||
if (isPdf && typeof pdfjsLib !== 'undefined') {
|
||||
this._loadPdf();
|
||||
} else {
|
||||
this._loadRasterImage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render first page of a PDF via PDF.js
|
||||
*/
|
||||
_loadPdf() {
|
||||
const self = this;
|
||||
pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) {
|
||||
return pdf.getPage(1);
|
||||
}).then(function (page) {
|
||||
const pdfScale = 2; // render at 2x for clarity
|
||||
const viewport = page.getViewport({ scale: pdfScale });
|
||||
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = viewport.width;
|
||||
offCanvas.height = viewport.height;
|
||||
const offCtx = offCanvas.getContext('2d');
|
||||
|
||||
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
|
||||
// Use rendered PDF page as if it were an image
|
||||
self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
console.error('AnnotationViewer: failed to load PDF:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a raster image (PNG, JPG, etc.)
|
||||
*/
|
||||
_loadRasterImage() {
|
||||
const self = this;
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit container
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
console.error('AnnotationViewer: parent container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight || 500; // fallback height
|
||||
|
||||
this.scale = Math.min(
|
||||
containerWidth / img.width,
|
||||
containerHeight / img.height,
|
||||
1 // non ingrandire oltre dimensione originale
|
||||
);
|
||||
|
||||
this.canvas.width = img.width * this.scale;
|
||||
this.canvas.height = img.height * this.scale;
|
||||
|
||||
// Draw image
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = true;
|
||||
|
||||
// Draw annotations on top
|
||||
this.drawAnnotations();
|
||||
img.onload = function () {
|
||||
self._drawImageOnCanvas(img, img.width, img.height);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
|
||||
img.onerror = function () {
|
||||
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
|
||||
};
|
||||
|
||||
img.src = this.imageUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw an image source (Image or Canvas) scaled to fit the container
|
||||
*/
|
||||
_drawImageOnCanvas(source, srcWidth, srcHeight) {
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
console.error('AnnotationViewer: parent container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight || 500;
|
||||
|
||||
this.scale = Math.min(
|
||||
containerWidth / srcWidth,
|
||||
containerHeight / srcHeight,
|
||||
1
|
||||
);
|
||||
|
||||
this.canvas.width = srcWidth * this.scale;
|
||||
this.canvas.height = srcHeight * this.scale;
|
||||
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = true;
|
||||
|
||||
this.drawAnnotations();
|
||||
},
|
||||
|
||||
/**
|
||||
* Disegna tutti i markers sulle annotazioni
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user