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