feat: FASE 4 - Editor Maker (Fabric.js) con annotazioni, task editor, preview e storico versioni
- recipe_list.html: lista ricette con filtri, paginazione, cards Alpine.js - recipe_editor.html: form metadati, upload drag-and-drop, canvas Fabric.js per annotazioni - annotation-editor.js: editor annotazioni Fabric.js (marker, frecce, rettangoli, zoom, pan) - task_editor.html: editor task/subtask inline con drag-and-drop reorder e tolleranze - recipe_preview.html: anteprima ricetta come MeasurementTec - version_history.html: timeline versioni con conteggio misurazioni AJAX - maker.py: 6 route pagina + 13 proxy AJAX, gestione sicura risposte lista API - i18n: 170+ stringhe tradotte IT/EN per tutti i template Maker Architect review: 3 CRITICO + 5 MEDIO + 3 NEW risolti, 2 BASSO differiti Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,872 @@
|
||||
/**
|
||||
* Annotation Editor - Fabric.js-based editor for image annotations
|
||||
*
|
||||
* Alpine.js component for the Maker role to place measurement markers,
|
||||
* arrows, and area rectangles on technical drawings.
|
||||
*
|
||||
* Dependencies:
|
||||
* - Fabric.js v5.3.1 (loaded via CDN before this file)
|
||||
* - Alpine.js (loaded with defer)
|
||||
*
|
||||
* Annotation JSON Format (output):
|
||||
* {
|
||||
* "version": 1,
|
||||
* "width": 800,
|
||||
* "height": 480,
|
||||
* "objects": [
|
||||
* { "type": "marker", "left": 150, "top": 200, "markerNumber": 1, ... },
|
||||
* { "type": "arrow", "left": 100, "top": 80, "x1": 100, "y1": 80, "x2": 300, "y2": 200, ... },
|
||||
* { "type": "area", "left": 50, "top": 60, "width": 150, "height": 100, ... }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* <div x-data="annotationEditor()"
|
||||
* x-init="loadBackgroundImage('/path/to/image.jpg')">
|
||||
* <canvas x-ref="fabricCanvas"></canvas>
|
||||
* </div>
|
||||
*/
|
||||
|
||||
function annotationEditor() {
|
||||
'use strict';
|
||||
|
||||
// ---- Debounce utility ----
|
||||
|
||||
function debounce(fn, delay) {
|
||||
var timer = null;
|
||||
return function () {
|
||||
var context = this;
|
||||
var args = arguments;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
fn.apply(context, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// ---- State ----
|
||||
|
||||
/** @type {fabric.Canvas|null} */
|
||||
canvas: null,
|
||||
/** @type {HTMLCanvasElement|null} */
|
||||
canvasEl: null,
|
||||
|
||||
/** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' | 'pan' */
|
||||
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,
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Initialization
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alpine init hook.
|
||||
* Creates the Fabric.js canvas instance and wires up all event listeners.
|
||||
*/
|
||||
init() {
|
||||
this.canvasEl = this.$refs.fabricCanvas;
|
||||
if (!this.canvasEl) {
|
||||
console.error('AnnotationEditor: canvas ref "fabricCanvas" not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvas = new fabric.Canvas(this.canvasEl, {
|
||||
selection: true,
|
||||
preserveObjectStacking: true,
|
||||
backgroundColor: '#f1f5f9',
|
||||
});
|
||||
|
||||
this.setupEvents();
|
||||
this.setupKeyboard();
|
||||
this.resizeCanvas();
|
||||
|
||||
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
|
||||
window.addEventListener('resize', this._debouncedResize);
|
||||
|
||||
// ---- Bridge: listen for commands from recipeEditor ----
|
||||
var self = this;
|
||||
|
||||
this._onToolChange = function (e) {
|
||||
if (e.detail && e.detail.tool) {
|
||||
self.setMode(e.detail.tool);
|
||||
}
|
||||
};
|
||||
window.addEventListener('anno-tool-change', this._onToolChange);
|
||||
|
||||
this._onDeleteSelected = function () {
|
||||
self.deleteSelected();
|
||||
};
|
||||
window.addEventListener('anno-delete-selected', this._onDeleteSelected);
|
||||
|
||||
this._onZoom = function (e) {
|
||||
if (e.detail && e.detail.delta > 0) {
|
||||
self.zoomIn();
|
||||
} else {
|
||||
self.zoomOut();
|
||||
}
|
||||
};
|
||||
window.addEventListener('anno-zoom', this._onZoom);
|
||||
|
||||
this._onZoomReset = function () {
|
||||
self.resetZoom();
|
||||
};
|
||||
window.addEventListener('anno-zoom-reset', this._onZoomReset);
|
||||
|
||||
this._onImageLoaded = function (e) {
|
||||
if (e.detail && e.detail.path) {
|
||||
// Wait for x-show transition to reveal the canvas
|
||||
setTimeout(function () {
|
||||
self.resizeCanvas();
|
||||
self.loadBackgroundImage('/api/files/' + e.detail.path);
|
||||
if (e.detail.annotations) {
|
||||
self.loadAnnotationsJson(e.detail.annotations);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
window.addEventListener('image-loaded', this._onImageLoaded);
|
||||
|
||||
// ---- Initial load: check parent scope for existing file ----
|
||||
var filePath = this.currentFilePath; // from parent recipeEditor scope
|
||||
if (filePath) {
|
||||
var annoJson = this.annotationsJson; // from parent recipeEditor scope
|
||||
this.loadBackgroundImage('/api/files/' + filePath);
|
||||
if (annoJson) {
|
||||
this.loadAnnotationsJson(annoJson);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Background image
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load an image as the canvas background.
|
||||
* Scales the image to fit within the canvas while maintaining aspect ratio
|
||||
* and never exceeding the original size (scale <= 1).
|
||||
*
|
||||
* @param {string} imageUrl - URL of the image to load
|
||||
*/
|
||||
loadBackgroundImage(imageUrl) {
|
||||
if (!this.canvas) return;
|
||||
|
||||
fabric.Image.fromURL(
|
||||
imageUrl,
|
||||
function (img) {
|
||||
if (!img || !img.width || !img.height) {
|
||||
console.error('AnnotationEditor: failed to load background image');
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = Math.min(
|
||||
this.canvas.width / img.width,
|
||||
this.canvas.height / img.height,
|
||||
1 // never upscale
|
||||
);
|
||||
|
||||
this.canvas.setBackgroundImage(
|
||||
img,
|
||||
this.canvas.renderAll.bind(this.canvas),
|
||||
{
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
}
|
||||
);
|
||||
|
||||
this.imageLoaded = true;
|
||||
}.bind(this),
|
||||
{ crossOrigin: 'anonymous' }
|
||||
);
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object creation - Markers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a numbered circle marker at the given position.
|
||||
*
|
||||
* @param {number} x - left position on canvas
|
||||
* @param {number} y - top position on canvas
|
||||
* @param {number} [number] - explicit marker number (auto-increments if omitted)
|
||||
* @returns {fabric.Group} the created marker group
|
||||
*/
|
||||
addMarker(x, y, number) {
|
||||
var markerNumber = number || this.nextMarkerNumber++;
|
||||
|
||||
var circle = new fabric.Circle({
|
||||
radius: 16,
|
||||
fill: '#2563EB',
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
var text = new fabric.Text(markerNumber.toString(), {
|
||||
fontSize: 14,
|
||||
fill: '#ffffff',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
});
|
||||
|
||||
var marker = new fabric.Group([circle, text], {
|
||||
left: x,
|
||||
top: y,
|
||||
selectable: true,
|
||||
hasControls: false,
|
||||
hasBorders: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
// Custom properties
|
||||
objectType: 'marker',
|
||||
markerNumber: markerNumber,
|
||||
});
|
||||
|
||||
this.canvas.add(marker);
|
||||
this.canvas.setActiveObject(marker);
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
return marker;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object creation - Arrows
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add an arrow (line + triangular arrowhead) between two points.
|
||||
*
|
||||
* @param {number} x1 - start x
|
||||
* @param {number} y1 - start y
|
||||
* @param {number} x2 - end x
|
||||
* @param {number} y2 - end y
|
||||
* @returns {fabric.Group} the created arrow group
|
||||
*/
|
||||
addArrow(x1, y1, x2, y2) {
|
||||
var line = new fabric.Line([x1, y1, x2, y2], {
|
||||
stroke: '#DC2626',
|
||||
strokeWidth: 2,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
});
|
||||
|
||||
var angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
|
||||
|
||||
var arrowHead = new fabric.Triangle({
|
||||
left: x2,
|
||||
top: y2,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
width: 12,
|
||||
height: 12,
|
||||
fill: '#DC2626',
|
||||
angle: angle + 90,
|
||||
});
|
||||
|
||||
var arrow = new fabric.Group([line, arrowHead], {
|
||||
selectable: true,
|
||||
objectType: 'arrow',
|
||||
// Store original endpoints for serialization
|
||||
arrowX1: x1,
|
||||
arrowY1: y1,
|
||||
arrowX2: x2,
|
||||
arrowY2: y2,
|
||||
});
|
||||
|
||||
this.canvas.add(arrow);
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
return arrow;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object creation - Rectangles
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a dashed-outline area rectangle.
|
||||
*
|
||||
* @param {number} x - left position
|
||||
* @param {number} y - top position
|
||||
* @param {number} [width=150] - rectangle width
|
||||
* @param {number} [height=100] - rectangle height
|
||||
* @returns {fabric.Rect} the created rectangle
|
||||
*/
|
||||
addRect(x, y, width, height) {
|
||||
var rect = new fabric.Rect({
|
||||
left: x,
|
||||
top: y,
|
||||
width: width || 150,
|
||||
height: height || 100,
|
||||
fill: 'rgba(37, 99, 235, 0.1)',
|
||||
stroke: '#2563EB',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 3],
|
||||
selectable: true,
|
||||
objectType: 'area',
|
||||
});
|
||||
|
||||
this.canvas.add(rect);
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
return rect;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Toolbar / mode switching
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set the current interaction mode.
|
||||
*
|
||||
* @param {'select'|'marker'|'arrow'|'rect'|'pan'} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this.activeMode = mode;
|
||||
|
||||
if (mode === 'select') {
|
||||
this.canvas.selection = true;
|
||||
this.canvas.defaultCursor = 'default';
|
||||
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;
|
||||
this.canvas.defaultCursor = 'crosshair';
|
||||
this.canvas.discardActiveObject();
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Mouse events
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wire up all Fabric.js mouse events for drawing, panning, selection,
|
||||
* and zoom.
|
||||
*/
|
||||
setupEvents() {
|
||||
var self = this;
|
||||
var isDrawing = false;
|
||||
var drawStartX = 0;
|
||||
var drawStartY = 0;
|
||||
var tempRect = null;
|
||||
var tempLine = null;
|
||||
var isPanning = false;
|
||||
var lastPanX = 0;
|
||||
var lastPanY = 0;
|
||||
|
||||
// ---- mouse:down ----
|
||||
|
||||
this.canvas.on('mouse:down', function (opt) {
|
||||
var pointer = self.canvas.getPointer(opt.e);
|
||||
|
||||
if (self.activeMode === 'marker') {
|
||||
self.addMarker(pointer.x, pointer.y);
|
||||
self.setMode('select');
|
||||
} else if (self.activeMode === 'arrow') {
|
||||
isDrawing = true;
|
||||
drawStartX = pointer.x;
|
||||
drawStartY = pointer.y;
|
||||
// Preview line
|
||||
tempLine = new fabric.Line(
|
||||
[drawStartX, drawStartY, drawStartX, drawStartY],
|
||||
{
|
||||
stroke: '#DC2626',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [4, 4],
|
||||
selectable: false,
|
||||
evented: false,
|
||||
objectType: '_temp',
|
||||
}
|
||||
);
|
||||
self.canvas.add(tempLine);
|
||||
} else if (self.activeMode === 'rect') {
|
||||
isDrawing = true;
|
||||
drawStartX = pointer.x;
|
||||
drawStartY = pointer.y;
|
||||
// Preview rect
|
||||
tempRect = new fabric.Rect({
|
||||
left: drawStartX,
|
||||
top: drawStartY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: 'rgba(37, 99, 235, 0.05)',
|
||||
stroke: '#2563EB',
|
||||
strokeWidth: 1,
|
||||
strokeDashArray: [4, 4],
|
||||
selectable: false,
|
||||
evented: false,
|
||||
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);
|
||||
|
||||
if (self.activeMode === 'arrow' && tempLine) {
|
||||
tempLine.set({ x2: pointer.x, y2: pointer.y });
|
||||
self.canvas.requestRenderAll();
|
||||
} else if (self.activeMode === 'rect' && tempRect) {
|
||||
var x = Math.min(drawStartX, pointer.x);
|
||||
var y = Math.min(drawStartY, pointer.y);
|
||||
var w = Math.abs(pointer.x - drawStartX);
|
||||
var h = Math.abs(pointer.y - drawStartY);
|
||||
tempRect.set({ left: x, top: y, width: w, height: h });
|
||||
self.canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- mouse:up ----
|
||||
|
||||
this.canvas.on('mouse:up', function (opt) {
|
||||
// Clean up temp preview objects
|
||||
if (tempLine) {
|
||||
self.canvas.remove(tempLine);
|
||||
tempLine = null;
|
||||
}
|
||||
if (tempRect) {
|
||||
self.canvas.remove(tempRect);
|
||||
tempRect = null;
|
||||
}
|
||||
|
||||
if (isDrawing) {
|
||||
var pointer = self.canvas.getPointer(opt.e);
|
||||
|
||||
if (self.activeMode === 'arrow') {
|
||||
var dx = pointer.x - drawStartX;
|
||||
var dy = pointer.y - drawStartY;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > 10) {
|
||||
self.addArrow(drawStartX, drawStartY, pointer.x, pointer.y);
|
||||
}
|
||||
} else if (self.activeMode === 'rect') {
|
||||
var w = Math.abs(pointer.x - drawStartX);
|
||||
var h = Math.abs(pointer.y - drawStartY);
|
||||
var x = Math.min(drawStartX, pointer.x);
|
||||
var y = Math.min(drawStartY, pointer.y);
|
||||
if (w > 5 && h > 5) {
|
||||
self.addRect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
isDrawing = false;
|
||||
self.setMode('select');
|
||||
}
|
||||
|
||||
if (isPanning) {
|
||||
isPanning = false;
|
||||
self.canvas.defaultCursor = 'grab';
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Selection events ----
|
||||
|
||||
this.canvas.on('selection:created', function (e) {
|
||||
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
|
||||
var selected = e.selected;
|
||||
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
|
||||
window.dispatchEvent(new CustomEvent('marker-selected', {
|
||||
detail: { markerNumber: selected[0].markerNumber }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.on('selection:updated', function (e) {
|
||||
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
|
||||
var selected = e.selected;
|
||||
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
|
||||
window.dispatchEvent(new CustomEvent('marker-selected', {
|
||||
detail: { markerNumber: selected[0].markerNumber }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.on('selection:cleared', function () {
|
||||
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: false } }));
|
||||
window.dispatchEvent(new CustomEvent('marker-selected', { detail: { markerNumber: null } }));
|
||||
});
|
||||
|
||||
// ---- Object modification ----
|
||||
|
||||
this.canvas.on('object:modified', function () {
|
||||
self.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
|
||||
});
|
||||
|
||||
this.canvas.on('object:moved', function () {
|
||||
self.isDirty = true;
|
||||
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();
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register global keyboard shortcuts.
|
||||
* Ignored when focus is inside input / textarea elements.
|
||||
*/
|
||||
setupKeyboard() {
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Skip when typing in form fields
|
||||
var tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (e.target.isContentEditable) return;
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
self.deleteSelected();
|
||||
} else if (e.key === 'Escape') {
|
||||
self.setMode('select');
|
||||
self.canvas.discardActiveObject();
|
||||
self.canvas.renderAll();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object manipulation
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete the currently selected object(s) from the canvas.
|
||||
*/
|
||||
deleteSelected() {
|
||||
var active = this.canvas.getActiveObject();
|
||||
if (!active) return;
|
||||
|
||||
if (active.type === 'activeSelection') {
|
||||
var self = this;
|
||||
active.forEachObject(function (obj) {
|
||||
self.canvas.remove(obj);
|
||||
});
|
||||
this.canvas.discardActiveObject();
|
||||
} else {
|
||||
this.canvas.remove(active);
|
||||
}
|
||||
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
this.canvas.renderAll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all annotation objects from the canvas (keeps background image).
|
||||
*/
|
||||
clearAll() {
|
||||
this.canvas.getObjects().slice().forEach(
|
||||
function (obj) {
|
||||
this.canvas.remove(obj);
|
||||
}.bind(this)
|
||||
);
|
||||
this.nextMarkerNumber = 1;
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
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
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resize the Fabric.js canvas to fill its parent container.
|
||||
* Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
|
||||
*/
|
||||
resizeCanvas() {
|
||||
var container = this.canvasEl ? this.canvasEl.parentElement : null;
|
||||
if (!container || !this.canvas) return;
|
||||
|
||||
var width = container.clientWidth;
|
||||
var height = Math.max(400, Math.round(width * 0.6));
|
||||
|
||||
this.canvas.setWidth(width);
|
||||
this.canvas.setHeight(height);
|
||||
this.canvas.renderAll();
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Serialization - Export
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serialize all canvas annotation objects to a JSON string.
|
||||
*
|
||||
* @returns {string} JSON representation of all annotations
|
||||
*/
|
||||
getAnnotationsJson() {
|
||||
var objects = this.canvas.getObjects().map(function (obj) {
|
||||
var data = {
|
||||
type: obj.objectType || obj.type,
|
||||
left: Math.round(obj.left * 100) / 100,
|
||||
top: Math.round(obj.top * 100) / 100,
|
||||
width: Math.round((obj.width || 0) * 100) / 100,
|
||||
height: Math.round((obj.height || 0) * 100) / 100,
|
||||
scaleX: Math.round((obj.scaleX || 1) * 1000) / 1000,
|
||||
scaleY: Math.round((obj.scaleY || 1) * 1000) / 1000,
|
||||
angle: Math.round((obj.angle || 0) * 100) / 100,
|
||||
markerNumber: obj.markerNumber || null,
|
||||
};
|
||||
|
||||
// Arrow-specific: store original endpoints
|
||||
if (obj.objectType === 'arrow') {
|
||||
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
||||
data.y1 = obj.arrowY1 != null ? Math.round(obj.arrowY1 * 100) / 100 : null;
|
||||
data.x2 = obj.arrowX2 != null ? Math.round(obj.arrowX2 * 100) / 100 : null;
|
||||
data.y2 = obj.arrowY2 != null ? Math.round(obj.arrowY2 * 100) / 100 : null;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
objects: objects,
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Serialization - Import
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load annotations from a JSON string and recreate all objects on the canvas.
|
||||
* Clears any existing annotation objects first (keeps background image).
|
||||
*
|
||||
* @param {string|Object} jsonString - JSON string or parsed object
|
||||
*/
|
||||
loadAnnotationsJson(jsonString) {
|
||||
if (!jsonString) return;
|
||||
|
||||
try {
|
||||
var data =
|
||||
typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;
|
||||
|
||||
// Remove existing objects
|
||||
this.canvas.getObjects().slice().forEach(
|
||||
function (obj) {
|
||||
this.canvas.remove(obj);
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
var objects = data.objects || [];
|
||||
|
||||
for (var i = 0; i < objects.length; i++) {
|
||||
var obj = objects[i];
|
||||
|
||||
if (obj.type === 'marker') {
|
||||
this.addMarker(obj.left, obj.top, obj.markerNumber);
|
||||
} else if (obj.type === 'arrow') {
|
||||
// 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);
|
||||
} else if (obj.type === 'area') {
|
||||
var w = (obj.width || 150) * (obj.scaleX || 1);
|
||||
var h = (obj.height || 100) * (obj.scaleY || 1);
|
||||
this.addRect(obj.left, obj.top, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate nextMarkerNumber
|
||||
var markerNumbers = objects
|
||||
.filter(function (o) {
|
||||
return o.type === 'marker' && o.markerNumber;
|
||||
})
|
||||
.map(function (o) {
|
||||
return o.markerNumber;
|
||||
});
|
||||
|
||||
this.nextMarkerNumber =
|
||||
markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1;
|
||||
|
||||
this.isDirty = false;
|
||||
this.canvas.renderAll();
|
||||
} catch (e) {
|
||||
console.error('AnnotationEditor: failed to load annotations:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Marker query helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a sorted list of all markers currently on the canvas.
|
||||
*
|
||||
* @returns {Array<{number: number, left: number, top: number}>}
|
||||
*/
|
||||
getMarkers() {
|
||||
return this.canvas
|
||||
.getObjects()
|
||||
.filter(function (obj) {
|
||||
return obj.objectType === 'marker';
|
||||
})
|
||||
.map(function (obj) {
|
||||
return {
|
||||
number: obj.markerNumber,
|
||||
left: Math.round(obj.left * 100) / 100,
|
||||
top: Math.round(obj.top * 100) / 100,
|
||||
};
|
||||
})
|
||||
.sort(function (a, b) {
|
||||
return a.number - b.number;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlight a specific marker by its number.
|
||||
* Selects it on the canvas so the user sees the Fabric selection border.
|
||||
*
|
||||
* @param {number} markerNumber
|
||||
*/
|
||||
selectMarkerByNumber(markerNumber) {
|
||||
var target = null;
|
||||
this.canvas.forEachObject(function (obj) {
|
||||
if (obj.objectType === 'marker' && obj.markerNumber === markerNumber) {
|
||||
target = obj;
|
||||
}
|
||||
});
|
||||
|
||||
if (target) {
|
||||
this.canvas.setActiveObject(target);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Destroy the Fabric.js canvas and remove event listeners.
|
||||
* Call this when the Alpine component is removed from the DOM.
|
||||
*/
|
||||
destroy() {
|
||||
if (this._debouncedResize) {
|
||||
window.removeEventListener('resize', this._debouncedResize);
|
||||
}
|
||||
if (this._onToolChange) {
|
||||
window.removeEventListener('anno-tool-change', this._onToolChange);
|
||||
}
|
||||
if (this._onDeleteSelected) {
|
||||
window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
|
||||
}
|
||||
if (this._onZoom) {
|
||||
window.removeEventListener('anno-zoom', this._onZoom);
|
||||
}
|
||||
if (this._onZoomReset) {
|
||||
window.removeEventListener('anno-zoom-reset', this._onZoomReset);
|
||||
}
|
||||
if (this._onImageLoaded) {
|
||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.dispose();
|
||||
this.canvas = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user