/** * 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: *
* *
*/ 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' */ activeMode: 'select', /** Next auto-assigned marker number */ nextMarkerNumber: 1, /** Whether a background image has been loaded */ imageLoaded: false, /** Whether the canvas has unsaved changes */ isDirty: false, /** Natural (unscaled) dimensions of the loaded background image */ _bgImageNatW: 0, _bgImageNatH: 0, /** Annotations JSON to load after background image is ready */ _pendingAnnotations: null, /** 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 // ---------------------------------------------------------------- /** * 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; } if (typeof fabric === 'undefined') { console.error('AnnotationEditor: Fabric.js not loaded'); return; } // Objects are move-only: no resize/rotate handles, just selection border fabric.Object.prototype.hasControls = false; fabric.Object.prototype.hasBorders = true; fabric.Object.prototype.borderColor = '#2563EB'; fabric.Object.prototype.lockRotation = true; fabric.Object.prototype.lockScalingX = true; fabric.Object.prototype.lockScalingY = true; // Disable object caching to avoid stale render states fabric.Object.prototype.objectCaching = false; try { this.canvas = new fabric.Canvas(this.canvasEl, { selection: true, preserveObjectStacking: true, backgroundColor: '#f1f5f9', enableRetinaScaling: false, }); } catch (err) { console.error('AnnotationEditor: failed to create canvas:', err); return; } var self = this; var canvas = this.canvas; // ---- Fabric.js v5 controls-visibility workaround ---- // // Fabric v5.3.1 draws object controls (resize/rotate handles) on the // LOWER canvas. The UPPER canvas (contextTop) sits on top and can // obscure those controls if it has stale content. // Fix: force contextTopDirty = true before every renderAll(). var _origRenderAll = canvas.renderAll.bind(canvas); canvas.renderAll = function () { canvas.contextTopDirty = true; return _origRenderAll(); }; // ---- Reliable pointer override ---- // // Fabric.js v5.3.1 getPointer can produce wrong coordinates due to // retina-scaling arithmetic, stale _offset cache, or scroll-offset // mismatches. Replace with a minimal BCR-based calculation that is // always correct: mouse-client-position minus canvas-BCR, scaled to // canvas coordinate space. canvas.getPointer = function (e, ignoreVpt) { // Honour Fabric's per-event cache (set by _cacheTransformEventData) if (canvas._absolutePointer && !ignoreVpt) { return canvas._absolutePointer; } if (canvas._pointer && ignoreVpt) { return canvas._pointer; } // Extract clientX/Y from mouse or touch event var clientX = 0, clientY = 0; if (e) { if (e.clientX != null) { clientX = e.clientX; clientY = e.clientY; } else if (e.changedTouches && e.changedTouches[0]) { clientX = e.changedTouches[0].clientX; clientY = e.changedTouches[0].clientY; } else if (e.touches && e.touches[0]) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } } // BCR of the upper canvas (where events are captured) var rect = canvas.upperCanvasEl.getBoundingClientRect(); // Position relative to canvas element in CSS pixels var x = clientX - rect.left; var y = clientY - rect.top; // Scale from CSS pixels to canvas internal coordinate space // With enableRetinaScaling:false these should be 1:1, but handle // any mismatch defensively. if (rect.width > 0 && rect.height > 0) { x = x * (canvas.width / rect.width); y = y * (canvas.height / rect.height); } // When ignoreVpt is false, return object-space coordinates if (!ignoreVpt) { return canvas.restorePointerVpt({ x: x, y: y }); } return { x: x, y: y }; }; // ---- Keep canvas offset fresh ---- canvas.on('mouse:down:before', function () { canvas.calcOffset(); }); // Force move-only properties on every object added to the canvas this.canvas.on('object:added', function (e) { var obj = e.target; if (obj && obj.objectType !== '_temp') { obj.hasControls = false; obj.hasBorders = true; obj.selectable = true; obj.evented = true; obj.lockRotation = true; obj.lockScalingX = true; obj.lockScalingY = true; obj.setCoords(); } }); this.setupEvents(); this.setupKeyboard(); this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200); window.addEventListener('resize', this._debouncedResize); // ---- Bridge: listen for commands from recipeEditor ---- 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._onClearAll = function () { self.clearAll(); }; window.addEventListener('anno-clear-all', this._onClearAll); 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; } active.dirty = true; self.canvas.renderAll(); self.isDirty = true; window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); } } }; window.addEventListener('anno-color-change', this._onColorChange); 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; } active.dirty = true; self.canvas.renderAll(); self.isDirty = true; window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); } } }; 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(); } active.dirty = true; 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) { // 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. * Annotations are deferred until the image callback fires so that * canvas dimensions and imageScale are known before placing objects. */ _loadImageAndAnnotations(imageUrl, annotationsJson) { this._pendingAnnotations = annotationsJson || null; this.loadBackgroundImage(imageUrl); }, // ---------------------------------------------------------------- // 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. * The canvas is resized to fit the image exactly (no empty space). */ _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; } self._bgImageNatW = img.width; self._bgImageNatH = img.height; self._fitCanvasToImage(img); self.imageLoaded = true; // Load pending annotations now that canvas is sized to the image if (self._pendingAnnotations) { self.loadAnnotationsJson(self._pendingAnnotations); self._pendingAnnotations = null; } } ); }, /** * Resize canvas to fit the background image exactly within the * container width. Canvas dimensions = image dimensions * scale, * so there is zero empty space and canvas coords map 1:1 to * scaled-image coords. * * @param {fabric.Image} img - Fabric image object (background) */ _fitCanvasToImage(img) { var fabricWrapper = this.canvasEl.parentElement; var container = fabricWrapper ? fabricWrapper.parentElement : null; if (!container) return; var containerWidth = container.clientWidth; if (!containerWidth) return; var natW = img.width || this._bgImageNatW; var natH = img.height || this._bgImageNatH; if (!natW || !natH) return; // Scale to fit container width; never upscale var scale = Math.min(containerWidth / natW, 1); var canvasW = Math.round(natW * scale); var canvasH = Math.round(natH * scale); this.canvas.setDimensions({ width: canvasW, height: canvasH }); this.canvas.setBackgroundImage( img, this.canvas.renderAll.bind(this.canvas), { scaleX: scale, scaleY: scale, originX: 'left', originY: 'top', } ); this.canvas.calcOffset(); this.canvas.forEachObject(function (obj) { obj.setCoords(); }); this.canvas.renderAll(); }, // ---------------------------------------------------------------- // 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: this.currentColor, 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, evented: true, hasControls: false, hasBorders: true, lockRotation: true, lockScalingX: true, lockScalingY: true, // Custom properties objectType: 'marker', markerNumber: markerNumber, markerColor: this.currentColor, }); this.canvas.add(marker); marker.setCoords(); 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: this.currentColor, strokeWidth: this.currentStrokeWidth, strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null, 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: 8 + this.currentStrokeWidth * 2, height: 8 + this.currentStrokeWidth * 2, fill: this.currentColor, angle: angle + 90, }); var arrow = new fabric.Group([line, arrowHead], { selectable: true, evented: true, hasControls: false, hasBorders: true, lockRotation: true, lockScalingX: true, lockScalingY: true, objectType: 'arrow', // Store original endpoints for serialization arrowX1: x1, arrowY1: y1, arrowX2: x2, arrowY2: y2, arrowColor: this.currentColor, arrowStrokeWidth: this.currentStrokeWidth, arrowLineDash: this.currentLineDash.slice(), }); this.canvas.add(arrow); arrow.setCoords(); this.canvas.setActiveObject(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(0, 0, 0, 0)', stroke: this.currentColor, strokeWidth: this.currentStrokeWidth, strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null, selectable: true, evented: true, hasControls: false, hasBorders: true, lockRotation: true, lockScalingX: true, lockScalingY: true, objectType: 'area', areaColor: this.currentColor, areaStrokeWidth: this.currentStrokeWidth, areaLineDash: this.currentLineDash.slice(), }); this.canvas.add(rect); rect.setCoords(); this.canvas.setActiveObject(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'} 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; o.evented = true; }); } else { // marker, arrow, rect — disable object interaction during drawing this.canvas.selection = false; this.canvas.defaultCursor = 'crosshair'; this.canvas.discardActiveObject(); this.canvas.forEachObject(function (o) { o.selectable = false; o.evented = false; }); } this.canvas.renderAll(); }, // ---------------------------------------------------------------- // Mouse events // ---------------------------------------------------------------- /** * Wire up all Fabric.js mouse events for drawing and selection. */ setupEvents() { var self = this; var isDrawing = false; var drawStartX = 0; var drawStartY = 0; var tempRect = null; var tempLine = null; // ---- mouse:down ---- this.canvas.on('mouse:down', function (opt) { // In select mode, let Fabric.js handle clicks without interference if (self.activeMode === 'select') return; var pointer = self.canvas.getPointer(opt.e); 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; 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); } }); // ---- mouse:move ---- this.canvas.on('mouse:move', function (opt) { 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'); window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } })); } }); // ---- Selection events ---- this.canvas.on('selection:created', function (e) { 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', { detail: { markerNumber: selected[0].markerNumber } })); } // Force coords + re-render so controls are visible on lower canvas var active = self.canvas.getActiveObject(); if (active) { active.setCoords(); self.canvas.requestRenderAll(); } }); this.canvas.on('selection:updated', function (e) { 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', { detail: { markerNumber: selected[0].markerNumber } })); } // Force coords + re-render so controls are visible on lower canvas var active = self.canvas.getActiveObject(); if (active) { active.setCoords(); self.canvas.requestRenderAll(); } }); 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 (e) { // Update arrow stored endpoints after a move so serialization // reflects the current position (not the original creation coords). var obj = e.target; if (obj && obj.objectType === 'arrow' && e.transform) { var dx = obj.left - e.transform.original.left; var dy = obj.top - e.transform.original.top; obj.arrowX1 = (obj.arrowX1 || 0) + dx; obj.arrowY1 = (obj.arrowY1 || 0) + dy; obj.arrowX2 = (obj.arrowX2 || 0) + dx; obj.arrowY2 = (obj.arrowY2 || 0) + dy; } self.isDirty = true; window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); }); }, // ---------------------------------------------------------------- // 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(); }, // ---------------------------------------------------------------- // 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 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; // If a background image is loaded, fit canvas to its dimensions if (this.canvas.backgroundImage && this._bgImageNatW && this._bgImageNatH) { this._fitCanvasToImage(this.canvas.backgroundImage); return; } // No image yet — use placeholder size so canvas is visible 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.setDimensions({ width: width, height: 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, 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; 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; }); // Save background image scale so the preview can convert canvas coords // to image coords correctly regardless of editor canvas dimensions. var bgImg = this.canvas.backgroundImage; var imageScale = bgImg ? (bgImg.scaleX || 1) : 1; return JSON.stringify({ version: 2, width: this.canvas.width, height: this.canvas.height, imageScale: imageScale, 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; // Coordinate scale factor: annotations may have been saved at a // different canvas size (different screen width). Since canvas // coords = imageCoords * imageScale, we convert: // currentCoord = savedCoord * (currentImageScale / savedImageScale) var savedImageScale = data.imageScale || 1; var bgImg = this.canvas.backgroundImage; var currentImageScale = bgImg ? (bgImg.scaleX || 1) : 1; var cs = currentImageScale / savedImageScale; // coordinate scale // 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') { var savedColor = this.currentColor; if (obj.fill) this.currentColor = obj.fill; this.addMarker(obj.left * cs, obj.top * cs, 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, scaled to current canvas var x1 = (obj.x1 != null ? obj.x1 : obj.left) * cs; var y1 = (obj.y1 != null ? obj.y1 : obj.top) * cs; var x2 = (obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100)) * cs; var y2 = (obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50)) * cs; 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 = []; this.addRect(obj.left * cs, obj.top * cs, (obj.width || 150) * cs, (obj.height || 100) * cs); this.currentColor = savedColor3; this.currentStrokeWidth = savedWidth2; this.currentLineDash = savedDash2; } } // 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.discardActiveObject(); 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._onClearAll) { window.removeEventListener('anno-clear-all', this._onClearAll); } if (this._onColorChange) { window.removeEventListener('anno-color-change', this._onColorChange); } 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); } if (this.canvas) { this.canvas.dispose(); this.canvas = null; } }, }; }