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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user