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
+158 -112
View File
@@ -52,21 +52,27 @@ function annotationEditor() {
/** @type {HTMLCanvasElement|null} */
canvasEl: null,
/** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' | 'pan' */
/** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' */
activeMode: 'select',
/** Next auto-assigned marker number */
nextMarkerNumber: 1,
/** Current zoom level (1 = 100%) */
zoomLevel: 1,
/** Whether a background image has been loaded */
imageLoaded: false,
/** Whether the canvas has unsaved changes */
isDirty: false,
/** Current color for new annotations */
currentColor: '#2563EB',
/** Current stroke width for arrows and rectangles */
currentStrokeWidth: 2,
/** Current line dash pattern: [] = solid, [5,3] = dashed, [2,2] = dotted */
currentLineDash: [],
// ----------------------------------------------------------------
// Initialization
// ----------------------------------------------------------------
@@ -82,11 +88,21 @@ function annotationEditor() {
return;
}
this.canvas = new fabric.Canvas(this.canvasEl, {
selection: true,
preserveObjectStacking: true,
backgroundColor: '#f1f5f9',
});
if (typeof fabric === 'undefined') {
console.error('AnnotationEditor: Fabric.js not loaded');
return;
}
try {
this.canvas = new fabric.Canvas(this.canvasEl, {
selection: true,
preserveObjectStacking: true,
backgroundColor: '#f1f5f9',
});
} catch (err) {
console.error('AnnotationEditor: failed to create canvas:', err);
return;
}
this.setupEvents();
this.setupKeyboard();
@@ -109,19 +125,76 @@ function annotationEditor() {
};
window.addEventListener('anno-delete-selected', this._onDeleteSelected);
this._onZoom = function (e) {
if (e.detail && e.detail.delta > 0) {
self.zoomIn();
} else {
self.zoomOut();
this._onColorChange = function (e) {
if (e.detail && e.detail.color) {
self.currentColor = e.detail.color;
// Update selected object
var active = self.canvas ? self.canvas.getActiveObject() : null;
if (active) {
if (active.objectType === 'marker') {
var items = active.getObjects();
if (items[0]) items[0].set('fill', e.detail.color);
active.markerColor = e.detail.color;
} else if (active.objectType === 'arrow') {
var items = active.getObjects();
if (items[0]) items[0].set('stroke', e.detail.color);
if (items[1]) items[1].set('fill', e.detail.color);
active.arrowColor = e.detail.color;
} else if (active.objectType === 'area') {
active.set('stroke', e.detail.color);
active.areaColor = e.detail.color;
}
self.canvas.renderAll();
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
}
}
};
window.addEventListener('anno-zoom', this._onZoom);
window.addEventListener('anno-color-change', this._onColorChange);
this._onZoomReset = function () {
self.resetZoom();
this._onStrokeWidthChange = function (e) {
if (e.detail && e.detail.width) {
self.currentStrokeWidth = e.detail.width;
var active = self.canvas ? self.canvas.getActiveObject() : null;
if (active) {
if (active.objectType === 'arrow') {
var items = active.getObjects();
if (items[0]) items[0].set('strokeWidth', e.detail.width);
if (items[1]) items[1].set({ width: 8 + e.detail.width * 2, height: 8 + e.detail.width * 2 });
active.arrowStrokeWidth = e.detail.width;
} else if (active.objectType === 'area') {
active.set('strokeWidth', e.detail.width);
active.areaStrokeWidth = e.detail.width;
}
self.canvas.renderAll();
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
}
}
};
window.addEventListener('anno-zoom-reset', this._onZoomReset);
window.addEventListener('anno-stroke-width-change', this._onStrokeWidthChange);
this._onLineDashChange = function (e) {
if (e.detail) {
self.currentLineDash = e.detail.dash || [];
var active = self.canvas ? self.canvas.getActiveObject() : null;
if (active) {
var dashArr = self.currentLineDash.length > 0 ? self.currentLineDash.slice() : null;
if (active.objectType === 'arrow') {
var items = active.getObjects();
if (items[0]) items[0].set('strokeDashArray', dashArr);
active.arrowLineDash = self.currentLineDash.slice();
} else if (active.objectType === 'area') {
active.set('strokeDashArray', dashArr);
active.areaLineDash = self.currentLineDash.slice();
}
self.canvas.renderAll();
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
}
}
};
window.addEventListener('anno-line-dash-change', this._onLineDashChange);
this._onImageLoaded = function (e) {
if (e.detail && e.detail.path) {
@@ -307,7 +380,7 @@ function annotationEditor() {
var circle = new fabric.Circle({
radius: 16,
fill: '#2563EB',
fill: this.currentColor,
originX: 'center',
originY: 'center',
stroke: '#ffffff',
@@ -334,6 +407,7 @@ function annotationEditor() {
// Custom properties
objectType: 'marker',
markerNumber: markerNumber,
markerColor: this.currentColor,
});
this.canvas.add(marker);
@@ -358,8 +432,9 @@ function annotationEditor() {
*/
addArrow(x1, y1, x2, y2) {
var line = new fabric.Line([x1, y1, x2, y2], {
stroke: '#DC2626',
strokeWidth: 2,
stroke: this.currentColor,
strokeWidth: this.currentStrokeWidth,
strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
originX: 'center',
originY: 'center',
});
@@ -371,9 +446,9 @@ function annotationEditor() {
top: y2,
originX: 'center',
originY: 'center',
width: 12,
height: 12,
fill: '#DC2626',
width: 8 + this.currentStrokeWidth * 2,
height: 8 + this.currentStrokeWidth * 2,
fill: this.currentColor,
angle: angle + 90,
});
@@ -385,6 +460,9 @@ function annotationEditor() {
arrowY1: y1,
arrowX2: x2,
arrowY2: y2,
arrowColor: this.currentColor,
arrowStrokeWidth: this.currentStrokeWidth,
arrowLineDash: this.currentLineDash.slice(),
});
this.canvas.add(arrow);
@@ -412,12 +490,15 @@ function annotationEditor() {
top: y,
width: width || 150,
height: height || 100,
fill: 'rgba(37, 99, 235, 0.1)',
stroke: '#2563EB',
strokeWidth: 2,
strokeDashArray: [5, 3],
fill: 'rgba(0, 0, 0, 0)',
stroke: this.currentColor,
strokeWidth: this.currentStrokeWidth,
strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
selectable: true,
objectType: 'area',
areaColor: this.currentColor,
areaStrokeWidth: this.currentStrokeWidth,
areaLineDash: this.currentLineDash.slice(),
});
this.canvas.add(rect);
@@ -433,7 +514,7 @@ function annotationEditor() {
/**
* Set the current interaction mode.
*
* @param {'select'|'marker'|'arrow'|'rect'|'pan'} mode
* @param {'select'|'marker'|'arrow'|'rect'} mode
*/
setMode(mode) {
this.activeMode = mode;
@@ -444,12 +525,6 @@ function annotationEditor() {
this.canvas.forEachObject(function (o) {
o.selectable = true;
});
} else if (mode === 'pan') {
this.canvas.selection = false;
this.canvas.defaultCursor = 'grab';
this.canvas.forEachObject(function (o) {
o.selectable = false;
});
} else {
// marker, arrow, rect
this.canvas.selection = false;
@@ -465,8 +540,7 @@ function annotationEditor() {
// ----------------------------------------------------------------
/**
* Wire up all Fabric.js mouse events for drawing, panning, selection,
* and zoom.
* Wire up all Fabric.js mouse events for drawing and selection.
*/
setupEvents() {
var self = this;
@@ -475,9 +549,6 @@ function annotationEditor() {
var drawStartY = 0;
var tempRect = null;
var tempLine = null;
var isPanning = false;
var lastPanX = 0;
var lastPanY = 0;
// ---- mouse:down ----
@@ -487,6 +558,7 @@ function annotationEditor() {
if (self.activeMode === 'marker') {
self.addMarker(pointer.x, pointer.y);
self.setMode('select');
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
} else if (self.activeMode === 'arrow') {
isDrawing = true;
drawStartX = pointer.x;
@@ -523,27 +595,12 @@ function annotationEditor() {
objectType: '_temp',
});
self.canvas.add(tempRect);
} else if (self.activeMode === 'pan') {
isPanning = true;
lastPanX = opt.e.clientX;
lastPanY = opt.e.clientY;
self.canvas.defaultCursor = 'grabbing';
}
});
// ---- mouse:move ----
this.canvas.on('mouse:move', function (opt) {
if (isPanning) {
var vpt = self.canvas.viewportTransform;
vpt[4] += opt.e.clientX - lastPanX;
vpt[5] += opt.e.clientY - lastPanY;
lastPanX = opt.e.clientX;
lastPanY = opt.e.clientY;
self.canvas.requestRenderAll();
return;
}
if (!isDrawing) return;
var pointer = self.canvas.getPointer(opt.e);
@@ -595,18 +652,16 @@ function annotationEditor() {
isDrawing = false;
self.setMode('select');
}
if (isPanning) {
isPanning = false;
self.canvas.defaultCursor = 'grab';
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
}
});
// ---- Selection events ----
this.canvas.on('selection:created', function (e) {
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
var sel = e.selected && e.selected[0];
var objType = sel ? sel.objectType : null;
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true, objectType: objType } }));
var selected = e.selected;
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
window.dispatchEvent(new CustomEvent('marker-selected', {
@@ -616,7 +671,9 @@ function annotationEditor() {
});
this.canvas.on('selection:updated', function (e) {
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
var sel = e.selected && e.selected[0];
var objType = sel ? sel.objectType : null;
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true, objectType: objType } }));
var selected = e.selected;
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
window.dispatchEvent(new CustomEvent('marker-selected', {
@@ -642,21 +699,6 @@ function annotationEditor() {
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
// ---- Zoom with mouse wheel ----
this.canvas.on('mouse:wheel', function (opt) {
var delta = opt.e.deltaY;
var zoom = self.canvas.getZoom();
zoom *= Math.pow(0.999, delta);
zoom = Math.max(0.25, Math.min(5, zoom));
self.canvas.zoomToPoint(
{ x: opt.e.offsetX, y: opt.e.offsetY },
zoom
);
self.zoomLevel = zoom;
opt.e.preventDefault();
opt.e.stopPropagation();
});
},
// ----------------------------------------------------------------
@@ -728,37 +770,6 @@ function annotationEditor() {
this.canvas.renderAll();
},
// ----------------------------------------------------------------
// Zoom controls
// ----------------------------------------------------------------
/**
* Zoom in by 20%.
*/
zoomIn() {
var zoom = Math.min(this.canvas.getZoom() * 1.2, 5);
this.canvas.setZoom(zoom);
this.zoomLevel = zoom;
},
/**
* Zoom out by 20%.
*/
zoomOut() {
var zoom = Math.max(this.canvas.getZoom() / 1.2, 0.25);
this.canvas.setZoom(zoom);
this.zoomLevel = zoom;
},
/**
* Reset zoom and pan to default (1:1, origin at top-left).
*/
resetZoom() {
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
this.canvas.setZoom(1);
this.zoomLevel = 1;
},
// ----------------------------------------------------------------
// Canvas sizing
// ----------------------------------------------------------------
@@ -811,6 +822,15 @@ function annotationEditor() {
markerNumber: obj.markerNumber || null,
};
// Color and stroke width
if (obj.markerColor) data.fill = obj.markerColor;
if (obj.arrowColor) data.stroke = obj.arrowColor;
if (obj.areaColor) data.stroke = obj.areaColor;
if (obj.arrowStrokeWidth) data.strokeWidth = obj.arrowStrokeWidth;
if (obj.areaStrokeWidth) data.strokeWidth = obj.areaStrokeWidth;
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
// Arrow-specific: store original endpoints
if (obj.objectType === 'arrow') {
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
@@ -860,18 +880,41 @@ function annotationEditor() {
var obj = objects[i];
if (obj.type === 'marker') {
var savedColor = this.currentColor;
if (obj.fill) this.currentColor = obj.fill;
this.addMarker(obj.left, obj.top, obj.markerNumber);
this.currentColor = savedColor;
} else if (obj.type === 'arrow') {
var savedColor2 = this.currentColor;
var savedWidth = this.currentStrokeWidth;
var savedDash = this.currentLineDash.slice();
if (obj.stroke) this.currentColor = obj.stroke;
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);
this.addArrow(x1, y1, x2, y2);
this.currentColor = savedColor2;
this.currentStrokeWidth = savedWidth;
this.currentLineDash = savedDash;
} else if (obj.type === 'area') {
var savedColor3 = this.currentColor;
var savedWidth2 = this.currentStrokeWidth;
var savedDash2 = this.currentLineDash.slice();
if (obj.stroke) this.currentColor = obj.stroke;
if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
if (obj.lineDash) this.currentLineDash = obj.lineDash;
else this.currentLineDash = [];
var w = (obj.width || 150) * (obj.scaleX || 1);
var h = (obj.height || 100) * (obj.scaleY || 1);
this.addRect(obj.left, obj.top, w, h);
this.currentColor = savedColor3;
this.currentStrokeWidth = savedWidth2;
this.currentLineDash = savedDash2;
}
}
@@ -959,11 +1002,14 @@ function annotationEditor() {
if (this._onDeleteSelected) {
window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
}
if (this._onZoom) {
window.removeEventListener('anno-zoom', this._onZoom);
if (this._onColorChange) {
window.removeEventListener('anno-color-change', this._onColorChange);
}
if (this._onZoomReset) {
window.removeEventListener('anno-zoom-reset', this._onZoomReset);
if (this._onStrokeWidthChange) {
window.removeEventListener('anno-stroke-width-change', this._onStrokeWidthChange);
}
if (this._onLineDashChange) {
window.removeEventListener('anno-line-dash-change', this._onLineDashChange);
}
if (this._onImageLoaded) {
window.removeEventListener('image-loaded', this._onImageLoaded);
+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);
}
}
}