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);