Files
TieMeasureFlow/client/static/js/annotation-editor.js
T
Adriano b075115cef fix: file display, persistence, PDF support and save error handling
- Add file proxy route in maker blueprint (X-API-Key auth for browser requests)
- Persist file_path/annotations_json to DB via RecipeCreate/RecipeUpdate schemas
- Fix canvas sizing using grandparent container instead of Fabric.js wrapper div
- Defer canvas init with requestAnimationFrame for x-show timing
- Add PDF.js support in annotation-editor and annotation-viewer
- Fix annotations_json double-serialization (parse string to object before send)
- Handle FastAPI 422 validation error arrays in api_client and JS error display
- Update template URLs to use /maker/api/files/ proxy path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:04:45 +01:00

978 lines
30 KiB
JavaScript

/**
* 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._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 and DOM layout
self._deferredLoad(
'/maker/api/files/' + e.detail.path,
e.detail.annotations || null
);
}
};
window.addEventListener('image-loaded', this._onImageLoaded);
// ---- Initial load: check parent scope for existing file ----
// Deferred to requestAnimationFrame so the browser has laid out the
// container (x-cloak removed, x-show applied) before we read dimensions.
var filePath = this.currentFilePath; // from parent recipeEditor scope
if (filePath) {
this._deferredLoad(
'/maker/api/files/' + filePath,
this.annotationsJson || null
);
} else {
// No file yet — still resize so canvas is ready when image is uploaded
requestAnimationFrame(function () {
self.resizeCanvas();
});
}
},
/**
* Deferred load: waits for the DOM to be fully laid out (via
* requestAnimationFrame) then resizes the canvas and loads the image.
* Falls back to a second attempt after 300ms if the container still
* has zero width (e.g. Alpine x-show transition not yet complete).
*/
_deferredLoad(imageUrl, annotationsJson) {
var self = this;
requestAnimationFrame(function () {
self.resizeCanvas();
// If container is still hidden / zero-width, retry after transition
if (!self.canvas.width) {
setTimeout(function () {
self.resizeCanvas();
self._loadImageAndAnnotations(imageUrl, annotationsJson);
}, 350);
} else {
self._loadImageAndAnnotations(imageUrl, annotationsJson);
}
});
},
/**
* Internal: load background image and optionally annotations.
*/
_loadImageAndAnnotations(imageUrl, annotationsJson) {
this.loadBackgroundImage(imageUrl);
if (annotationsJson) {
this.loadAnnotationsJson(annotationsJson);
}
},
// ----------------------------------------------------------------
// Background image
// ----------------------------------------------------------------
/**
* Load an image or PDF as the canvas background.
* For PDFs, renders the first page via PDF.js then uses it as background.
* Scales to fit within the canvas while maintaining aspect ratio.
*
* @param {string} imageUrl - URL of the image or PDF to load
*/
loadBackgroundImage(imageUrl) {
if (!this.canvas) return;
if (!this.canvas.width) {
this.resizeCanvas();
}
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
this._loadPdfBackground(imageUrl);
} else {
this._loadImageBackground(imageUrl);
}
},
/**
* Render first page of a PDF via PDF.js and set as canvas background.
*/
_loadPdfBackground(pdfUrl) {
var self = this;
var loadingTask = pdfjsLib.getDocument(pdfUrl);
loadingTask.promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
// Render at 2x scale for crisp display
var scale = 2;
var viewport = page.getViewport({ scale: scale });
// Off-screen canvas for rendering
var offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
var ctx = offCanvas.getContext('2d');
var renderContext = {
canvasContext: ctx,
viewport: viewport,
};
page.render(renderContext).promise.then(function () {
// Convert rendered PDF page to data URL, then load as Fabric image
var dataUrl = offCanvas.toDataURL('image/png');
self._loadImageBackground(dataUrl);
});
}).catch(function (err) {
console.error('AnnotationEditor: failed to load PDF:', err);
});
},
/**
* Load a raster image URL as the canvas background.
*/
_loadImageBackground(imageUrl) {
var self = this;
fabric.Image.fromURL(
imageUrl,
function (img) {
if (!img || !img.width || !img.height) {
console.error('AnnotationEditor: failed to load background image from', imageUrl);
return;
}
if (!self.canvas.width) {
self.resizeCanvas();
}
var cw = self.canvas.width || 800;
var ch = self.canvas.height || 480;
var scale = Math.min(
cw / img.width,
ch / img.height,
1 // never upscale
);
self.canvas.setBackgroundImage(
img,
self.canvas.renderAll.bind(self.canvas),
{
scaleX: scale,
scaleY: scale,
originX: 'left',
originY: 'top',
}
);
self.imageLoaded = true;
}
);
},
// ----------------------------------------------------------------
// 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).
*
* Note: Fabric.js wraps the <canvas> in its own div, so parentElement
* is the Fabric wrapper (which has a fixed pixel width from creation).
* We need the .canvas-container grandparent for the real layout width.
*/
resizeCanvas() {
if (!this.canvasEl || !this.canvas) return;
// Fabric wrapper → .canvas-container (or whatever holds the canvas)
var fabricWrapper = this.canvasEl.parentElement;
var container = fabricWrapper ? fabricWrapper.parentElement : null;
if (!container) 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.calcOffset();
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;
}
},
};
}