feat: per-task image/annotations, annotation editor toolbar, Tailwind compiled

- Add per-task file upload with image preview in task editor
- Add dedicated annotation editor page (task_drawing.html) with Fabric.js
- Add color picker, stroke width, and line dash controls to annotation toolbar
- Apply property changes to selected objects in real-time
- Disable style controls until a drawing tool or object is selected
- Remove zoom/pan from annotation toolbar (simplified UX)
- Auto-switch to select mode after placing annotation elements
- Show annotation overlay on task image previews (read-only canvas)
- Add file proxy route in measure blueprint for task file access
- Add file_path/file_type fields to TaskCreate/TaskUpdate Pydantic schemas
- Replace Tailwind CDN with compiled CSS (tailwind.config.js with full shades)
- Fix Alpine.js x-init crash: extract annotations JSON to <script> tags
  (recipe_preview.html, task_execute.html) to avoid HTML attribute breakage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-08 01:20:34 +01:00
parent b075115cef
commit 6ea94cca47
14 changed files with 1290 additions and 555 deletions
+194
View File
@@ -314,3 +314,197 @@ function annotationViewer() {
}
};
}
/**
* Standalone function to render an image with annotations overlay.
* For use in read-only previews (e.g. task cards) without Alpine.js.
*
* @param {HTMLCanvasElement} canvas - the canvas element to draw on
* @param {string} imageUrl - URL of the image to load
* @param {Object|string|null} annotationsJson - annotations data from the editor
* @param {Object} [options] - optional settings
* @param {number} [options.maxHeight=192] - max canvas height in pixels
*/
function renderAnnotationPreview(canvas, imageUrl, annotationsJson, options) {
if (!canvas || !imageUrl) return;
var opts = options || {};
var maxHeight = opts.maxHeight || 192;
var ctx = canvas.getContext('2d');
var annotations = null;
if (annotationsJson) {
try {
annotations = (typeof annotationsJson === 'string')
? JSON.parse(annotationsJson)
: annotationsJson;
} catch (e) {
console.error('renderAnnotationPreview: invalid annotations JSON');
}
}
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
pdfjsLib.getDocument(imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
var viewport = page.getViewport({ scale: 2 });
var offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
var offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
_drawPreview(canvas, ctx, offCanvas, viewport.width, viewport.height, annotations, maxHeight);
});
}).catch(function (err) {
console.error('renderAnnotationPreview: PDF load failed:', err);
});
} else {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
_drawPreview(canvas, ctx, img, img.width, img.height, annotations, maxHeight);
};
img.onerror = function () {
console.error('renderAnnotationPreview: image load failed:', imageUrl);
};
img.src = imageUrl;
}
}
/**
* Internal: draw image + annotations on canvas, scaled to fit.
*/
function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) {
var container = canvas.parentElement;
var containerWidth = container ? container.clientWidth : 400;
var scale = Math.min(containerWidth / srcW, maxHeight / srcH, 1);
canvas.width = Math.round(srcW * scale);
canvas.height = Math.round(srcH * scale);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if (annotations && annotations.objects) {
var annoScale = canvas.width / (annotations.width || srcW);
_drawEditorAnnotations(ctx, annotations, annoScale);
} else if (annotations && annotations.markers) {
_drawLegacyAnnotations(ctx, annotations, scale);
}
}
/**
* Draw annotations in the editor format (objects: marker/arrow/area).
*/
function _drawEditorAnnotations(ctx, data, scale) {
var objects = data.objects || [];
// Calculate annotation scale: annotations were placed on a canvas of
// data.width x data.height, but we're drawing on a scaled canvas.
// The image was scaled to fit data.width x data.height in the editor,
// and now we scale again for preview. Use overall scale factor.
var annoScale = scale;
for (var i = 0; i < objects.length; i++) {
var obj = objects[i];
if (obj.type === 'marker') {
_drawPreviewMarker(ctx, obj, annoScale);
} else if (obj.type === 'arrow') {
_drawPreviewArrow(ctx, obj, annoScale);
} else if (obj.type === 'area') {
_drawPreviewArea(ctx, obj, annoScale);
}
}
}
function _drawPreviewMarker(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var color = obj.fill || '#2563EB';
var radius = 14 * Math.min(scale, 1);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
if (obj.markerNumber) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold ' + Math.max(10, Math.round(12 * Math.min(scale, 1))) + 'px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(obj.markerNumber.toString(), x, y);
}
}
function _drawPreviewArrow(ctx, obj, scale) {
var x1 = (obj.x1 != null ? obj.x1 : obj.left) * scale;
var y1 = (obj.y1 != null ? obj.y1 : obj.top) * scale;
var x2 = (obj.x2 != null ? obj.x2 : (obj.left + (obj.width || 100))) * scale;
var y2 = (obj.y2 != null ? obj.y2 : (obj.top + (obj.height || 50))) * scale;
var color = obj.stroke || '#DC2626';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [];
ctx.setLineDash(lineDash);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
ctx.setLineDash([]);
// Arrowhead
var angle = Math.atan2(y2 - y1, x2 - x1);
var headLen = 8 + lineWidth * 2;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function _drawPreviewArea(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var w = (obj.width || 150) * (obj.scaleX || 1) * scale;
var h = (obj.height || 100) * (obj.scaleY || 1) * scale;
var color = obj.stroke || '#2563EB';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [5, 3];
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDash);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
/**
* Draw annotations in the legacy format (markers: point/rectangle).
*/
function _drawLegacyAnnotations(ctx, data, scale) {
var markers = data.markers || [];
for (var i = 0; i < markers.length; i++) {
var m = markers[i];
if (m.type === 'point') {
_drawPreviewMarker(ctx, { left: m.x, top: m.y, markerNumber: m.marker_number, fill: '#64748B' }, scale);
} else if (m.type === 'rectangle') {
_drawPreviewArea(ctx, { left: m.x, top: m.y, width: m.width, height: m.height, stroke: '#64748B' }, scale);
}
}
}