/** * Document Bounding Box Annotation * * Provides bounding box drawing and management for HTML/document displays. * Works with rendered HTML content and captures bbox coordinates. */ (function() { 'use strict'; // Store all bounding boxes by field key const boundingBoxes = {}; let selectedBox = null; let currentMode = 'draw'; // 'draw' or 'select' let isDrawing = false; let isResizing = false; let resizeHandle = null; // 'nw', 'ne', 'sw', 'se' let drawStart = null; let resizeStart = null; let currentContainer = null; const HANDLE_SIZE = 8; // Color palette for labels - distinct, visually appealing colors const LABEL_COLORS = [ '#e74c3c', // Red '#3498db', // Blue '#2ecc71', // Green '#9b59b6', // Purple '#f39c12', // Orange '#1abc9c', // Teal '#e91e63', // Pink '#00bcd4', // Cyan '#ff5722', // Deep Orange '#607d8b', // Blue Grey '#8bc34a', // Light Green '#673ab7', // Deep Purple ]; // Cache for label-to-color mapping const labelColorCache = {}; /** * Get a consistent color for a label. * Uses hash-based assignment so the same label always gets the same color. */ function getLabelColor(label) { if (!label) return '#0066cc'; // Default blue for unlabeled // Return cached color if available if (labelColorCache[label]) { return labelColorCache[label]; } // Generate hash from label string let hash = 0; for (let i = 0; i < label.length; i++) { const char = label.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } // Use absolute value and modulo to get index const colorIndex = Math.abs(hash) % LABEL_COLORS.length; const color = LABEL_COLORS[colorIndex]; // Cache and return labelColorCache[label] = color; return color; } /** * Convert hex color to RGBA with specified alpha. */ function hexToRgba(hex, alpha) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } /** * Get the currently selected label from the annotation scheme. */ function getCurrentSelectedLabel() { // Check for selected radio button const selectedRadio = document.querySelector('input[type="radio"]:checked'); if (selectedRadio) { return selectedRadio.value || selectedRadio.getAttribute('data-label') || selectedRadio.getAttribute('data-name'); } return null; } /** * Initialize bounding box annotation for all document displays in bbox mode. */ function initDocumentBoundingBoxes() { const displays = document.querySelectorAll('.document-display[data-annotation-mode="bounding_box"]'); displays.forEach(initDisplay); } /** * Initialize a single document display for bbox annotation. */ function initDisplay(container) { currentContainer = container; const fieldKey = container.getAttribute('data-field-key'); const minSize = parseInt(container.getAttribute('data-bbox-min-size') || '10', 10); const showLabels = container.getAttribute('data-show-bbox-labels') !== 'false'; // Initialize bbox storage if (!boundingBoxes[fieldKey]) { boundingBoxes[fieldKey] = []; } // Set up canvas for drawing const bboxCanvas = container.querySelector('.document-bbox-canvas'); const contentEl = container.querySelector('.document-bbox-content'); if (bboxCanvas && contentEl) { initDrawingCanvas(bboxCanvas, contentEl, container, minSize, showLabels); } // Set up tool buttons initToolButtons(container); // Load any existing annotations loadExistingAnnotations(container, fieldKey); // Handle resize const resizeObserver = new ResizeObserver(() => { syncCanvasSize(container); }); resizeObserver.observe(contentEl); } /** * Get the resize handle at a given point for the selected box. * Returns 'nw', 'ne', 'sw', 'se' or null. */ function getResizeHandleAtPoint(point, box, canvas) { if (!box) return null; const pixelBbox = box.bbox_pixels || [ box.bbox[0] * canvas.width, box.bbox[1] * canvas.height, box.bbox[2] * canvas.width, box.bbox[3] * canvas.height ]; const [x, y, w, h] = pixelBbox; const handles = { 'nw': { x: x, y: y }, 'ne': { x: x + w, y: y }, 'sw': { x: x, y: y + h }, 'se': { x: x + w, y: y + h } }; for (const [handle, pos] of Object.entries(handles)) { if (Math.abs(point.x - pos.x) <= HANDLE_SIZE && Math.abs(point.y - pos.y) <= HANDLE_SIZE) { return handle; } } return null; } /** * Update cursor based on position over handles. */ function updateCursor(canvas, point, box) { const handle = getResizeHandleAtPoint(point, box, canvas); if (handle) { // Show resize cursor when over a handle if (handle === 'nw' || handle === 'se') { canvas.style.cursor = 'nwse-resize'; } else { canvas.style.cursor = 'nesw-resize'; } } else { // Default cursor based on mode canvas.style.cursor = currentMode === 'draw' ? 'crosshair' : 'pointer'; } } /** * Initialize the drawing canvas for bounding boxes. */ function initDrawingCanvas(canvas, contentEl, container, minSize, showLabels) { const ctx = canvas.getContext('2d'); // Initial size sync syncCanvasSize(container); // Mouse events for drawing and resizing canvas.addEventListener('mousedown', function(e) { const rect = canvas.getBoundingClientRect(); const mousePos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Check for resize handle first - allow in ANY mode if there's a selected box // This lets users resize immediately after drawing without switching modes if (selectedBox) { const handle = getResizeHandleAtPoint(mousePos, selectedBox, canvas); if (handle) { isResizing = true; resizeHandle = handle; resizeStart = mousePos; e.preventDefault(); return; } } // Draw mode - start drawing if (currentMode === 'draw') { isDrawing = true; drawStart = mousePos; } }); canvas.addEventListener('mousemove', function(e) { const rect = canvas.getBoundingClientRect(); const currentPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Update cursor - show resize cursor when hovering over handles (any mode) if (selectedBox) { updateCursor(canvas, currentPos, selectedBox); } else if (currentMode === 'draw') { canvas.style.cursor = 'crosshair'; } else { canvas.style.cursor = 'pointer'; } // Handle resizing if (isResizing && selectedBox) { resizeSelectedBox(container, canvas, currentPos); return; } // Handle drawing if (isDrawing && currentMode === 'draw') { redrawCanvas(container); drawTemporaryRect(ctx, drawStart, currentPos); } }); canvas.addEventListener('mouseup', function(e) { const rect = canvas.getBoundingClientRect(); const endPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Finish resizing if (isResizing) { isResizing = false; resizeHandle = null; resizeStart = null; // Update normalized bbox from pixel bbox if (selectedBox) { updateNormalizedBbox(selectedBox, canvas); triggerBoundingBoxEvent(container, 'document-bbox:resized', selectedBox); } redrawCanvas(container); return; } // Finish drawing if (isDrawing && currentMode === 'draw') { isDrawing = false; // Check minimum size const width = Math.abs(endPos.x - drawStart.x); const height = Math.abs(endPos.y - drawStart.y); if (width >= minSize && height >= minSize) { createBoundingBox(container, drawStart, endPos); } redrawCanvas(container); } }); canvas.addEventListener('mouseleave', function() { if (isDrawing) { isDrawing = false; redrawCanvas(container); } if (isResizing) { isResizing = false; resizeHandle = null; if (selectedBox) { updateNormalizedBbox(selectedBox, canvas); } redrawCanvas(container); } }); // Click to select in select mode (only if not resizing) canvas.addEventListener('click', function(e) { if (currentMode !== 'select') return; if (isResizing) return; const rect = canvas.getBoundingClientRect(); const clickPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Don't select if clicking on a resize handle if (selectedBox && getResizeHandleAtPoint(clickPos, selectedBox, canvas)) { return; } selectBoxAtPoint(container, clickPos); }); } /** * Resize the selected box based on handle being dragged. */ function resizeSelectedBox(container, canvas, currentPos) { if (!selectedBox || !resizeHandle) return; const pixelBbox = selectedBox.bbox_pixels; let [x, y, w, h] = pixelBbox; const minSize = parseInt(container.getAttribute('data-bbox-min-size') || '10', 10); switch (resizeHandle) { case 'nw': // Top-left const newW_nw = (x + w) - currentPos.x; const newH_nw = (y + h) - currentPos.y; if (newW_nw >= minSize && newH_nw >= minSize) { x = currentPos.x; y = currentPos.y; w = newW_nw; h = newH_nw; } break; case 'ne': // Top-right const newW_ne = currentPos.x - x; const newH_ne = (y + h) - currentPos.y; if (newW_ne >= minSize && newH_ne >= minSize) { y = currentPos.y; w = newW_ne; h = newH_ne; } break; case 'sw': // Bottom-left const newW_sw = (x + w) - currentPos.x; const newH_sw = currentPos.y - y; if (newW_sw >= minSize && newH_sw >= minSize) { x = currentPos.x; w = newW_sw; h = newH_sw; } break; case 'se': // Bottom-right const newW_se = currentPos.x - x; const newH_se = currentPos.y - y; if (newW_se >= minSize && newH_se >= minSize) { w = newW_se; h = newH_se; } break; } // Update pixel bbox selectedBox.bbox_pixels = [x, y, w, h]; redrawCanvas(container); } /** * Update normalized bbox from pixel bbox. */ function updateNormalizedBbox(box, canvas) { if (!box.bbox_pixels) return; const [x, y, w, h] = box.bbox_pixels; box.bbox = [ x / canvas.width, y / canvas.height, w / canvas.width, h / canvas.height ]; } /** * Sync canvas size with content element. */ function syncCanvasSize(container) { const canvas = container.querySelector('.document-bbox-canvas'); const contentEl = container.querySelector('.document-bbox-content'); if (canvas && contentEl) { canvas.width = contentEl.offsetWidth; canvas.height = contentEl.offsetHeight; redrawCanvas(container); } } /** * Draw a temporary rectangle while drawing. */ function drawTemporaryRect(ctx, start, end) { // Get color based on currently selected label (if any) const currentLabel = getCurrentSelectedLabel(); const color = getLabelColor(currentLabel); ctx.strokeStyle = color; ctx.fillStyle = hexToRgba(color, 0.15); ctx.lineWidth = 2; ctx.setLineDash([5, 5]); const x = Math.min(start.x, end.x); const y = Math.min(start.y, end.y); const w = Math.abs(end.x - start.x); const h = Math.abs(end.y - start.y); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); } /** * Create a new bounding box. */ function createBoundingBox(container, start, end) { const fieldKey = container.getAttribute('data-field-key'); const canvas = container.querySelector('.document-bbox-canvas'); // Calculate normalized coordinates (0-1) const x = Math.min(start.x, end.x) / canvas.width; const y = Math.min(start.y, end.y) / canvas.height; const width = Math.abs(end.x - start.x) / canvas.width; const height = Math.abs(end.y - start.y) / canvas.height; // Generate unique ID const boxId = `bbox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Get currently selected label (if any) const currentLabel = getCurrentSelectedLabel(); // Create box object const box = { id: boxId, bbox: [x, y, width, height], bbox_pixels: [ Math.min(start.x, end.x), Math.min(start.y, end.y), Math.abs(end.x - start.x), Math.abs(end.y - start.y) ], label: currentLabel, // Auto-assign current label created_at: new Date().toISOString() }; // Store box boundingBoxes[fieldKey].push(box); // Update display redrawCanvas(container); updateBoxCount(container); // Trigger event triggerBoundingBoxEvent(container, 'document-bbox:created', box); // Prompt for label (allows changing if needed) promptForLabel(container, box); } /** * Prompt user to select a label for the bounding box. * If no label was auto-assigned, this allows selecting one. */ function promptForLabel(container, box) { // Always select the box after drawing (for resize handles) selectedBox = box; redrawCanvas(container); // If box already has a label (from auto-assignment), no need to add listeners if (box.label) { return; } const labelButtons = document.querySelectorAll('.span-label-btn, .annotation-label, input[type="radio"]'); if (labelButtons.length > 0) { // Capture the specific box reference (not using global selectedBox) const targetBox = box; const labelHandler = function(e) { const label = e.target.getAttribute('data-label') || e.target.getAttribute('data-name') || e.target.value || e.target.textContent.trim(); if (label && targetBox) { targetBox.label = label; redrawCanvas(container); triggerBoundingBoxEvent(container, 'document-bbox:labeled', targetBox); } labelButtons.forEach(btn => { btn.removeEventListener('click', labelHandler); btn.removeEventListener('change', labelHandler); }); }; labelButtons.forEach(btn => { btn.addEventListener('click', labelHandler); btn.addEventListener('change', labelHandler); }); } } /** * Select a bounding box at a given point. */ function selectBoxAtPoint(container, point) { const fieldKey = container.getAttribute('data-field-key'); const boxes = boundingBoxes[fieldKey] || []; const canvas = container.querySelector('.document-bbox-canvas'); selectedBox = null; // Find box at point (reverse order for top-most first) for (let i = boxes.length - 1; i >= 0; i--) { const box = boxes[i]; const pixelBbox = box.bbox_pixels || [ box.bbox[0] * canvas.width, box.bbox[1] * canvas.height, box.bbox[2] * canvas.width, box.bbox[3] * canvas.height ]; if (point.x >= pixelBbox[0] && point.x <= pixelBbox[0] + pixelBbox[2] && point.y >= pixelBbox[1] && point.y <= pixelBbox[1] + pixelBbox[3]) { selectedBox = box; break; } } redrawCanvas(container); triggerBoundingBoxEvent(container, 'document-bbox:selected', selectedBox); } /** * Delete the currently selected bounding box. */ function deleteSelectedBox(container) { if (!selectedBox) return; const fieldKey = container.getAttribute('data-field-key'); const boxes = boundingBoxes[fieldKey] || []; const index = boxes.findIndex(b => b.id === selectedBox.id); if (index !== -1) { boxes.splice(index, 1); triggerBoundingBoxEvent(container, 'document-bbox:deleted', selectedBox); selectedBox = null; redrawCanvas(container); updateBoxCount(container); } } /** * Redraw all bounding boxes on the canvas. */ function redrawCanvas(container) { const fieldKey = container.getAttribute('data-field-key'); const canvas = container.querySelector('.document-bbox-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const showLabels = container.getAttribute('data-show-bbox-labels') !== 'false'; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw all boxes const boxes = boundingBoxes[fieldKey] || []; boxes.forEach(box => { const isSelected = selectedBox && selectedBox.id === box.id; drawBoundingBox(ctx, box, canvas.width, canvas.height, isSelected, showLabels); }); } /** * Draw a single bounding box. */ function drawBoundingBox(ctx, box, canvasWidth, canvasHeight, isSelected, showLabels) { const pixelBbox = box.bbox_pixels || [ box.bbox[0] * canvasWidth, box.bbox[1] * canvasHeight, box.bbox[2] * canvasWidth, box.bbox[3] * canvasHeight ]; const [x, y, w, h] = pixelBbox; // Get color based on label const labelColor = getLabelColor(box.label); // Box style - use label color, with thicker stroke when selected if (isSelected) { ctx.strokeStyle = labelColor; ctx.fillStyle = hexToRgba(labelColor, 0.25); ctx.lineWidth = 3; } else { ctx.strokeStyle = labelColor; ctx.fillStyle = hexToRgba(labelColor, 0.1); ctx.lineWidth = 2; } ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); // Draw label if (showLabels && box.label) { ctx.font = 'bold 12px sans-serif'; const textWidth = ctx.measureText(box.label).width; const labelHeight = 20; const labelY = y - labelHeight - 2; // Label background - use label color ctx.fillStyle = labelColor; ctx.fillRect(x, Math.max(0, labelY), textWidth + 10, labelHeight); // Label text ctx.fillStyle = 'white'; ctx.fillText(box.label, x + 5, Math.max(14, labelY + 14)); } // Resize handles if selected if (isSelected) { ctx.fillStyle = labelColor; ctx.strokeStyle = 'white'; ctx.lineWidth = 1; // Corner handles with white border for visibility const handles = [ [x - HANDLE_SIZE/2, y - HANDLE_SIZE/2], [x + w - HANDLE_SIZE/2, y - HANDLE_SIZE/2], [x - HANDLE_SIZE/2, y + h - HANDLE_SIZE/2], [x + w - HANDLE_SIZE/2, y + h - HANDLE_SIZE/2] ]; handles.forEach(([hx, hy]) => { ctx.fillRect(hx, hy, HANDLE_SIZE, HANDLE_SIZE); ctx.strokeRect(hx, hy, HANDLE_SIZE, HANDLE_SIZE); }); } } /** * Initialize tool buttons. */ function initToolButtons(container) { const drawBtn = container.querySelector('.document-bbox-draw-btn'); const selectBtn = container.querySelector('.document-bbox-select-btn'); const deleteBtn = container.querySelector('.document-bbox-delete-btn'); if (drawBtn) { drawBtn.addEventListener('click', function() { setMode(container, 'draw'); }); } if (selectBtn) { selectBtn.addEventListener('click', function() { setMode(container, 'select'); }); } if (deleteBtn) { deleteBtn.addEventListener('click', function() { deleteSelectedBox(container); }); } } /** * Set the current annotation mode. */ function setMode(container, mode) { currentMode = mode; const drawBtn = container.querySelector('.document-bbox-draw-btn'); const selectBtn = container.querySelector('.document-bbox-select-btn'); const bboxContainer = container.querySelector('.document-bbox-container'); if (drawBtn) drawBtn.classList.toggle('active', mode === 'draw'); if (selectBtn) selectBtn.classList.toggle('active', mode === 'select'); if (bboxContainer) bboxContainer.classList.toggle('selecting', mode === 'select'); } /** * Update box count display. */ function updateBoxCount(container) { const fieldKey = container.getAttribute('data-field-key'); const boxes = boundingBoxes[fieldKey] || []; const countEl = container.querySelector('.document-bbox-count .count'); if (countEl) { countEl.textContent = boxes.length; } } /** * Find the hidden input that persists this widget's boxes through the * standard annotation save pipeline (class annotation-data-input, rendered * by document_display.py). Falls back to the legacy bbox_annotations name. */ function getDataInput(container) { return container.querySelector('input.annotation-data-input') || container.querySelector('input[name="bbox_annotations"]'); } /** * Persist the current boxes into the hidden input so saveAnnotations() * collects them as "{name}:::_data". F-040. */ function persistBoxes(container) { const input = getDataInput(container); if (!input) return; const fieldKey = container.getAttribute('data-field-key'); input.value = JSON.stringify(boundingBoxes[fieldKey] || []); // Let the autosave/validation pipeline know this changed. input.setAttribute('data-modified', 'true'); input.dispatchEvent(new Event('change', { bubbles: true })); } /** * Load existing annotations. */ function loadExistingAnnotations(container, fieldKey) { const existingData = getDataInput(container); if (existingData && existingData.value) { try { const annotations = JSON.parse(existingData.value); if (Array.isArray(annotations)) { boundingBoxes[fieldKey] = annotations; } } catch (e) { console.error('Error loading existing document bbox annotations:', e); } } redrawCanvas(container); updateBoxCount(container); } /** * Trigger a custom event. Also persists boxes to the hidden input, since * this runs on every box mutation (created/labeled/resized/deleted). */ function triggerBoundingBoxEvent(container, eventName, data) { persistBoxes(container); const event = new CustomEvent(eventName, { detail: { fieldKey: container.getAttribute('data-field-key'), data: data, allBoxes: getAllBoundingBoxes(container) }, bubbles: true }); container.dispatchEvent(event); } /** * Get all bounding boxes for a container. */ function getAllBoundingBoxes(container) { const fieldKey = container.getAttribute('data-field-key'); const boxes = boundingBoxes[fieldKey] || []; return boxes.map(box => ({ ...box, format_coords: { format: 'bounding_box', bbox: box.bbox, bbox_pixels: box.bbox_pixels, label: box.label } })); } /** * Export bounding boxes. */ function exportBoundingBoxes(container) { return getAllBoundingBoxes(container); } // Public API window.DocumentBoundingBox = { init: initDocumentBoundingBoxes, initDisplay: initDisplay, getAllBoxes: getAllBoundingBoxes, exportBoxes: exportBoundingBoxes, syncCanvasSize: syncCanvasSize, deleteSelected: function(container) { deleteSelectedBox(container); }, setMode: setMode }; // Auto-initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initDocumentBoundingBoxes); } else { initDocumentBoundingBoxes(); } })();