/** * Span Annotation Core - Positioning and Overlay Management * * This module provides unified text positioning for span annotations. */ // Debug logging utility - respects the debug setting from server config function spanCoreDebugLog(...args) { if (window.config && window.config.debug) { console.log(...args); } } function spanCoreDebugWarn(...args) { if (window.config && window.config.debug) { console.warn(...args); } } /** * Centralized Z-Index Management * All overlay z-index values defined in one place for consistency. * Higher values appear on top of lower values. */ const OVERLAY_Z_INDEX = { // Base layers (defined in HTML template) TEXT_CONTENT: 1, // #text-content OVERLAY_CONTAINER: 2, // #span-overlays container // Overlay types (higher = on top) ADMIN_KEYWORD: 100, // Admin-defined keyword highlights (dashed border) AI_KEYWORD: 110, // AI-suggested keyword highlights (solid border) USER_SPAN: 120, // User-created span annotations (filled) // Interactive elements (must be above overlays) SPAN_CONTROLS: 200, // Label + delete button TOOLTIP: 300 // Hover tooltips }; /** * Get font metrics for positioning calculations */ function getFontMetrics(container) { const computedStyle = window.getComputedStyle(container); const fontSize = parseFloat(computedStyle.fontSize); const fontFamily = computedStyle.fontFamily; const lineHeight = parseFloat(computedStyle.lineHeight) || fontSize * 1.2; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = `${fontSize}px ${fontFamily}`; const testChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '; const charWidths = {}; for (let char of testChars) { charWidths[char] = ctx.measureText(char).width; } const totalWidth = Object.values(charWidths).reduce((sum, width) => sum + width, 0); const averageCharWidth = totalWidth / Object.keys(charWidths).length; return { fontSize, fontFamily, lineHeight, averageCharWidth, charWidths, containerPadding: { top: parseFloat(computedStyle.paddingTop) || 0, left: parseFloat(computedStyle.paddingLeft) || 0, right: parseFloat(computedStyle.paddingRight) || 0, bottom: parseFloat(computedStyle.paddingBottom) || 0 } }; } /** * Unified Positioning Strategy for Text Span Annotation */ class UnifiedPositioningStrategy { constructor(container) { this.container = container; this.fontMetrics = null; this.canonicalText = null; this.isInitialized = false; } async initialize() { await this.waitForElements(); await this.waitForFontMetrics(); this.canonicalText = this.getCanonicalText(); this.fontMetrics = this.getFontMetrics(); this.isInitialized = true; } async waitForElements() { return new Promise((resolve) => { const check = () => { if (this.container && this.container.textContent && this.container.textContent.trim()) { resolve(); } else { setTimeout(check, 50); } }; check(); }); } async waitForFontMetrics() { return new Promise((resolve) => { if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => { setTimeout(resolve, 200); }); } else { setTimeout(resolve, 800); } }); } getCanonicalText() { if (this.container.hasAttribute('data-original-text')) { const originalText = this.container.getAttribute('data-original-text'); const cleanText = originalText.replace(/<[^>]*>/g, '').trim(); return this.normalizeText(cleanText); } const textContent = this.container.textContent || ''; return this.normalizeText(textContent); } normalizeText(text) { // For code displays (pre/code elements), preserve newlines for accurate positioning // Check if we're inside a code display if (this.container && ( this.container.closest('.code-display') || this.container.closest('.code-simple') || this.container.querySelector('pre') )) { // Only normalize multiple spaces to single space, keep newlines return text.replace(/[ \t]+/g, ' ').trim(); } // For regular text, normalize all whitespace return text.replace(/\s+/g, ' ').trim(); } getFontMetrics() { return getFontMetrics(this.container); } createSpanFromSelection(selection, options = {}) { if (!this.isInitialized) { return null; } const selectedText = selection.toString().trim(); if (!selectedText) { return null; } // Calculate offset directly from the selection range by walking the DOM // This avoids text normalization mismatches between indexOf and DOM text const offsets = this.getOffsetsFromSelection(selection); if (!offsets) { console.warn('[SpanCore] Could not calculate offsets from selection'); return null; } console.log('[SpanCore] createSpanFromSelection: container=' + (this.container ? this.container.id : 'NULL') + ' start=' + offsets.start + ' end=' + offsets.end + ' selected="' + selectedText + '"'); return this.createSpanWithAlgorithm(offsets.start, offsets.end, selectedText, options); } /** * Calculate character offsets from a selection by walking the DOM tree. * This gives us the exact position in the raw DOM text, avoiding normalization issues. */ getOffsetsFromSelection(selection) { if (!selection.rangeCount) return null; const range = selection.getRangeAt(0); const startContainer = range.startContainer; const endContainer = range.endContainer; const startOffset = range.startOffset; const endOffset = range.endOffset; // Walk through text nodes to find cumulative offset const textNodes = []; let cumulativeOffset = 0; const collectTextNodes = (node) => { if (node.nodeType === Node.TEXT_NODE) { textNodes.push({ node: node, start: cumulativeOffset, end: cumulativeOffset + node.textContent.length }); cumulativeOffset += node.textContent.length; } else if (node.nodeType === Node.ELEMENT_NODE) { // Skip overlay containers - their label/button text must not affect offset calculations if (node.id === 'span-overlays' || node.classList.contains('span-overlays-field')) { return; } for (const child of node.childNodes) { collectTextNodes(child); } } }; collectTextNodes(this.container); // Find the offset for start and end let absoluteStart = null; let absoluteEnd = null; for (const tn of textNodes) { if (tn.node === startContainer) { absoluteStart = tn.start + startOffset; } if (tn.node === endContainer) { absoluteEnd = tn.start + endOffset; } } // Handle case where start/end containers are element nodes if (absoluteStart === null && startContainer.nodeType === Node.ELEMENT_NODE) { // startOffset is the index of the child node if (startOffset < startContainer.childNodes.length) { const childNode = startContainer.childNodes[startOffset]; for (const tn of textNodes) { if (tn.node === childNode || tn.node.parentNode === childNode) { absoluteStart = tn.start; break; } } } } if (absoluteEnd === null && endContainer.nodeType === Node.ELEMENT_NODE) { if (endOffset > 0 && endOffset <= endContainer.childNodes.length) { const childNode = endContainer.childNodes[endOffset - 1]; for (const tn of textNodes) { if (tn.node === childNode || tn.node.parentNode === childNode) { absoluteEnd = tn.end; break; } } } } if (absoluteStart === null || absoluteEnd === null) { console.warn('[SpanCore] Could not find absolute offsets for selection', { startContainer: startContainer.nodeName, endContainer: endContainer.nodeName, startOffset, endOffset }); return null; } return { start: absoluteStart, end: absoluteEnd }; } createSpanWithAlgorithm(start, end, text, options = {}) { if (!this.isInitialized) { return null; } const canonicalText = this.getCanonicalText(); if (start < 0 || end > canonicalText.length || start >= end) { console.warn('[SpanCore] Invalid span positions:', { start, end, textLength: canonicalText.length }); return null; } // Use getPositionsFromOffsets (offset-based) instead of getTextPositions (indexOf-based). // getTextPositions ignores start/end and uses indexOf(text) on data-original-text, // which can return wrong positions when data-original-text differs from DOM text. const positions = this.getPositionsFromOffsets(start, end); if (!positions || positions.length === 0) { return null; } // Validate positions const invalidPositions = positions.filter(p => p.x < -1000 || p.y < -1000 || p.width <= 0 || p.height <= 0 || p.x > 10000 || p.y > 10000 || p.width > 5000 || p.height > 500 ); if (invalidPositions.length > 0) { console.warn('[SpanCore] Some positions look invalid:', invalidPositions); } const span = { // Use a temporary ID - will be replaced with deterministic ID when label/schema are set id: `span_${start}_${end}_${Date.now()}`, start: start, end: end, text: text, label: 'unknown', color: null }; // Store the positions for later ID generation span._tempPositions = { start, end }; // Pass options (including color) to createOverlay const overlay = this.createOverlay(span, positions, options); if (!overlay) { return null; } return { span, overlay, positions }; } getTextPositions(start, end, text) { // Use this.container (the element passed to the strategy constructor) // instead of hardcoded #text-content, so multi-field mode works const textElement = this.container || document.getElementById('text-content'); if (!textElement) { return null; } let actualText = textElement.getAttribute('data-original-text'); const domTextContent = textElement.textContent || textElement.innerText || ''; if (!actualText) { actualText = domTextContent; } const targetStart = actualText.indexOf(text); if (targetStart === -1) { return null; } const targetEnd = targetStart + text.length; const range = document.createRange(); // Collect all text nodes with their cumulative offsets const textNodes = []; let cumulativeOffset = 0; const collectTextNodes = (node) => { if (node.nodeType === Node.TEXT_NODE) { textNodes.push({ node: node, text: node.textContent, start: cumulativeOffset, end: cumulativeOffset + node.textContent.length }); cumulativeOffset += node.textContent.length; } else if (node.nodeType === Node.ELEMENT_NODE) { // Skip overlay containers - their label/button text must not affect offset calculations if (node.id === 'span-overlays' || node.classList.contains('span-overlays-field')) { return; } for (const child of node.childNodes) { collectTextNodes(child); } } }; collectTextNodes(textElement); if (textNodes.length === 0) { return null; } // Find the text nodes containing start and end positions let startNode = null, startOffset = 0; let endNode = null, endOffset = 0; for (const tn of textNodes) { if (startNode === null && targetStart >= tn.start && targetStart < tn.end) { startNode = tn.node; startOffset = targetStart - tn.start; } if (targetEnd > tn.start && targetEnd <= tn.end) { endNode = tn.node; endOffset = targetEnd - tn.start; } } try { range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); } catch (error) { console.error('[SpanCore] Error setting range:', error); return null; } const rects = range.getClientRects(); if (rects.length === 0) { return null; } // Use the text element itself for positioning reference // This ensures consistency with overlay positioning which is inside the text element const containerRect = textElement.getBoundingClientRect(); const positions = Array.from(rects).map((rect) => ({ x: rect.left - containerRect.left, y: rect.top - containerRect.top, width: rect.width, height: rect.height })); return positions; } /** * Get screen positions for text at specific character offsets. * Unlike getTextPositions(), this uses the provided offsets directly * instead of searching for the text with indexOf(). * * @param {number} start - Start character offset in the text * @param {number} end - End character offset in the text * @returns {Array<{x, y, width, height}>|null} Screen positions relative to container, or null on error */ getPositionsFromOffsets(start, end) { // Use this.container instead of hardcoded #text-content const textElement = this.container || document.getElementById('text-content'); if (!textElement) { console.warn('[SpanCore] getPositionsFromOffsets: text-content element not found'); return null; } // Collect text nodes with cumulative offsets const textNodes = []; let cumulativeOffset = 0; const collectTextNodes = (node) => { if (node.nodeType === Node.TEXT_NODE) { textNodes.push({ node: node, start: cumulativeOffset, end: cumulativeOffset + node.textContent.length }); cumulativeOffset += node.textContent.length; } else if (node.nodeType === Node.ELEMENT_NODE) { // Skip overlay containers - their label/button text must not affect offset calculations if (node.id === 'span-overlays' || node.classList.contains('span-overlays-field')) { return; } for (const child of node.childNodes) { collectTextNodes(child); } } }; collectTextNodes(textElement); if (textNodes.length === 0) { console.warn('[SpanCore] getPositionsFromOffsets: no text nodes found'); return null; } // Find nodes containing start and end positions let startNode = null, startOffset = 0; let endNode = null, endOffset = 0; for (const tn of textNodes) { // Find start node if (startNode === null && start >= tn.start && start < tn.end) { startNode = tn.node; startOffset = start - tn.start; } // Find end node (can be same as start node) if (end > tn.start && end <= tn.end) { endNode = tn.node; endOffset = end - tn.start; } } if (!startNode || !endNode) { console.warn('[SpanCore] getPositionsFromOffsets: could not find text nodes for offsets', { start, end, totalLength: cumulativeOffset }); return null; } // Create range and get bounding rectangles const range = document.createRange(); try { range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); } catch (e) { console.error('[SpanCore] getPositionsFromOffsets: error setting range:', e); return null; } const rects = range.getClientRects(); if (rects.length === 0) { console.warn('[SpanCore] getPositionsFromOffsets: no client rects returned'); return null; } // Convert to positions relative to the text element container itself // This ensures consistency with overlay positioning which is also relative to text-content const containerRect = textElement.getBoundingClientRect(); return Array.from(rects).map(rect => ({ x: rect.left - containerRect.left, y: rect.top - containerRect.top, width: rect.width, height: rect.height })); } createOverlay(span, positions, options = {}) { if (!positions || positions.length === 0) return null; const { isAiSpan = false, color = 'rgba(255, 255, 0, 0.3)' } = options; // Padding values for visual breathing room around text const HORIZONTAL_PADDING = 3; // px on each side const VERTICAL_PADDING = 2; // px on top and bottom const overlay = document.createElement('div'); overlay.className = isAiSpan ? 'span-overlay-ai' : 'span-overlay-pure'; overlay.dataset.annotationId = span.id; overlay.dataset.start = span.start; overlay.dataset.end = span.end; overlay.dataset.label = span.label; if (span.schema) { overlay.dataset.schema = span.schema; } if (span.target_field) { overlay.dataset.targetField = span.target_field; } if (isAiSpan) { overlay.dataset.isAiSpan = 'true'; } // Include entity linking data if present if (span.kb_id && span.kb_source) { overlay.dataset.kbId = span.kb_id; overlay.dataset.kbSource = span.kb_source; if (span.kb_label) { overlay.dataset.kbLabel = span.kb_label; } overlay.classList.add('has-entity-link'); } overlay.style.position = 'absolute'; // Anchor at top-left of #span-overlays. Without explicit top/left, the browser // uses the "hypothetical static position" which is offset by any rendered whitespace // inside #span-overlays (caused by white-space:pre-wrap inherited from #text-content). overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = isAiSpan ? OVERLAY_Z_INDEX.AI_KEYWORD : OVERLAY_Z_INDEX.USER_SPAN; positions.forEach((pos) => { const segment = document.createElement('div'); segment.className = 'span-highlight-segment'; segment.style.position = 'absolute'; // Add padding: position starts earlier, dimensions are larger segment.style.left = `${pos.x - HORIZONTAL_PADDING}px`; segment.style.top = `${pos.y - VERTICAL_PADDING}px`; segment.style.width = `${pos.width + 2 * HORIZONTAL_PADDING}px`; segment.style.height = `${pos.height + 2 * VERTICAL_PADDING}px`; if (isAiSpan) { // AI spans use a box/border style instead of background highlight segment.style.backgroundColor = 'transparent'; segment.style.border = `2px solid ${color}`; segment.style.borderRadius = '3px'; segment.style.boxShadow = `0 0 4px ${color}`; } else { segment.style.backgroundColor = color; segment.style.border = `1px solid ${color.replace('0.3', '0.8')}`; segment.style.borderRadius = '4px'; } segment.style.pointerEvents = 'none'; overlay.appendChild(segment); }); if (!isAiSpan) { // Regular spans get labels and delete buttons // Position label and delete button together above the segment start const segmentLeft = positions[0].x - HORIZONTAL_PADDING; const segmentTop = positions[0].y - VERTICAL_PADDING; // Create a container for label + delete button so they stay together // Position above the highlight - allow negative values to go above container const controlsContainer = document.createElement('div'); controlsContainer.className = 'span-controls'; controlsContainer.style.position = 'absolute'; controlsContainer.style.left = `${segmentLeft}px`; // Position 22px above the segment top (controls are ~18px tall) controlsContainer.style.top = `${segmentTop - 22}px`; controlsContainer.style.display = 'flex'; controlsContainer.style.alignItems = 'center'; controlsContainer.style.gap = '4px'; controlsContainer.style.pointerEvents = 'auto'; controlsContainer.style.zIndex = '10'; // Check if span labels should be shown (config: show_span_labels) const spanForm = document.querySelector('.annotation-form.span[data-show-span-labels="false"]'); const showLabels = !spanForm; if (showLabels) { const label = document.createElement('div'); label.className = 'span-label'; label.textContent = span.label; // Override CSS positioning to work in flex container label.style.position = 'static'; label.style.top = 'auto'; label.style.left = 'auto'; label.style.backgroundColor = 'rgba(0, 0, 0, 0.85)'; label.style.color = 'white'; label.style.padding = '2px 6px'; label.style.borderRadius = '3px'; label.style.fontSize = '11px'; label.style.fontWeight = '500'; label.style.whiteSpace = 'nowrap'; label.style.display = 'block'; controlsContainer.appendChild(label); } const deleteBtn = document.createElement('button'); deleteBtn.className = 'span-delete-btn'; deleteBtn.textContent = '×'; deleteBtn.style.backgroundColor = 'rgba(220, 53, 69, 0.9)'; deleteBtn.style.color = 'white'; deleteBtn.style.border = 'none'; deleteBtn.style.borderRadius = '50%'; deleteBtn.style.width = '16px'; deleteBtn.style.height = '16px'; deleteBtn.style.minWidth = '16px'; deleteBtn.style.minHeight = '16px'; deleteBtn.style.padding = '0'; deleteBtn.style.margin = '0'; deleteBtn.style.fontSize = '12px'; deleteBtn.style.fontFamily = 'Arial, sans-serif'; deleteBtn.style.fontWeight = 'bold'; deleteBtn.style.lineHeight = '1'; deleteBtn.style.textAlign = 'center'; deleteBtn.style.cursor = 'pointer'; deleteBtn.style.display = 'flex'; deleteBtn.style.alignItems = 'center'; deleteBtn.style.justifyContent = 'center'; deleteBtn.style.flexShrink = '0'; // Reset any CSS positioning that might interfere deleteBtn.style.position = 'static'; deleteBtn.style.top = 'auto'; deleteBtn.style.right = 'auto'; deleteBtn.style.left = 'auto'; deleteBtn.onclick = (e) => { e.stopPropagation(); // Immediately remove this overlay from DOM for instant visual feedback const overlayElement = e.target.closest('.span-overlay-pure'); if (overlayElement) { overlayElement.remove(); } // Then process the server-side delete if (window.spanManager) { window.spanManager.deleteSpan(span.id); } }; controlsContainer.appendChild(deleteBtn); overlay.appendChild(controlsContainer); } return overlay; } validateSpan(span) { if (!this.isInitialized) return false; const { start, end, text } = span; if (start >= end || start < 0 || end > this.canonicalText.length) return false; const coveredText = this.canonicalText.substring(start, end); return coveredText === text; } } /** * Frontend Span Manager for Potato Annotation Platform */ class SpanManager { constructor() { this.annotations = { spans: [] }; this.colors = {}; this.selectedLabel = null; this.selectedTargetField = ''; this.currentSchema = null; this.isInitialized = false; this.currentInstanceId = null; this.lastKnownInstanceId = null; this.positioningStrategy = null; this.schemas = {}; // Multi-span support: per-field positioning strategies this.fieldStrategies = {}; // { fieldKey: UnifiedPositioningStrategy } // AI span state tracking this.aiSpans = new Map(); // Map> // Admin keyword highlight state tracking this.keywordHighlights = []; // Array of keyword highlight overlay elements // Discontinuous span support: track active span being extended this.discontinuousMode = false; this.activeDiscontinuousSpan = null; // Span being extended with additional parts } // ==================== SCHEMA STATE MANAGEMENT ==================== /** * Set the current schema with validation and logging. * This is the ONLY place currentSchema should be modified. * * @param {string} schema - The schema name to set * @param {string} source - Where this call originated (for debugging) * @returns {boolean} - Whether the schema was set successfully */ setCurrentSchema(schema, source = 'unknown') { if (!schema) { console.warn(`[SpanManager] setCurrentSchema called with empty schema from ${source}`); return false; } // Validate schema exists if we have schemas loaded if (Object.keys(this.schemas).length > 0 && !this.schemas[schema]) { console.warn(`[SpanManager] Schema '${schema}' not found in loaded schemas. Source: ${source}. Available: ${Object.keys(this.schemas).join(', ')}`); // Still set it - might be valid but not yet loaded } const oldSchema = this.currentSchema; this.currentSchema = schema; if (oldSchema !== schema) { console.debug(`[SpanManager] Schema changed: '${oldSchema}' -> '${schema}' (source: ${source})`); } return true; } // ==================== AI SPAN METHODS ==================== /** * Insert AI-suggested span highlights for keywords * @param {Array} keywords - Array of keyword objects with {label, start, end, text, reasoning} * @param {string} annotationId - The annotation ID these AI spans belong to */ /** * Insert AI keyword highlights with bordered (unfilled) overlays * @param {Array} highlights - Array of {label, start, end, text, schema} * @param {string} annotationId - The annotation ID */ insertAiKeywordHighlights(highlights, annotationId) { console.log('[SpanManager] insertAiKeywordHighlights called:', { highlights, annotationId }); if (!highlights || !Array.isArray(highlights) || highlights.length === 0) { console.log('[SpanManager] No highlights to insert'); return; } this.deleteOneAiSpan(annotationId); const spanOverlays = document.getElementById('span-overlays'); if (!spanOverlays) { console.log('[SpanManager] span-overlays element not found'); return; } const createdOverlays = []; highlights.forEach((highlight) => { const { label, start, end, text, schema } = highlight; if (!this.positioningStrategy || !this.positioningStrategy.isInitialized) { console.warn('[SpanManager] Positioning strategy not initialized'); return; } // Use getPositionsFromOffsets() which respects the provided offsets // instead of getTextPositions() which does indexOf() and ignores offsets const positions = this.positioningStrategy.getPositionsFromOffsets(start, end); if (!positions || positions.length === 0) { console.warn('[SpanManager] No positions found for highlight:', { start, end, text }); return; } // Get color for this label from schema colors const color = this.getAiKeywordColor(label, schema); const span = { id: `ai_keyword_${annotationId}_${start}_${end}_${Date.now()}`, start: start, end: end, text: text, label: label }; // Create bordered overlay (not filled) const overlay = this.createBorderedOverlay(span, positions, color); if (overlay) { overlay.dataset.aiAnnotationId = annotationId; overlay.title = `${label}: "${text}"`; spanOverlays.appendChild(overlay); createdOverlays.push(overlay); } }); if (createdOverlays.length > 0) { this.aiSpans.set(annotationId, createdOverlays); } } /** * Create a bordered (unfilled) overlay for keyword highlighting. * Used for AI keyword highlights and admin keyword highlights. */ createBorderedOverlay(span, positions, color) { // Padding for visual breathing room const HORIZONTAL_PADDING = 2; const VERTICAL_PADDING = 1; const overlay = document.createElement('div'); overlay.className = 'span-overlay ai-keyword-overlay'; overlay.dataset.spanId = span.id; overlay.dataset.label = span.label; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = OVERLAY_Z_INDEX.AI_KEYWORD; positions.forEach((pos) => { const segment = document.createElement('div'); segment.className = 'span-segment ai-keyword-segment'; segment.style.position = 'absolute'; // FIX: Use correct property names (x, y not left, top) segment.style.left = `${pos.x - HORIZONTAL_PADDING}px`; segment.style.top = `${pos.y - VERTICAL_PADDING}px`; segment.style.width = `${pos.width + 2 * HORIZONTAL_PADDING}px`; segment.style.height = `${pos.height + 2 * VERTICAL_PADDING}px`; segment.style.border = `2px solid ${color}`; segment.style.borderRadius = '3px'; segment.style.backgroundColor = 'transparent'; segment.style.pointerEvents = 'none'; segment.style.boxSizing = 'border-box'; overlay.appendChild(segment); }); return overlay; } /** * Get color for AI keyword highlight based on label */ getAiKeywordColor(label, schemaName) { // Try to get from loaded colors (with case-insensitive fallback) if (this.colors && schemaName && this.colors[schemaName]) { const schemaColors = this.colors[schemaName]; // Try exact match first if (schemaColors[label]) { const color = schemaColors[label]; if (color.startsWith('(')) { return `rgba${color.replace(')', ', 0.8)')}`; } return color; } // Try case-insensitive match const lowerLabel = label.toLowerCase(); for (const [key, color] of Object.entries(schemaColors)) { if (key.toLowerCase() === lowerLabel) { if (color.startsWith('(')) { return `rgba${color.replace(')', ', 0.8)')}`; } return color; } } } // Fallback colors for common labels (case-insensitive) const fallbackColors = { 'positive': 'rgba(34, 197, 94, 0.8)', // green 'negative': 'rgba(239, 68, 68, 0.8)', // red 'neutral': 'rgba(156, 163, 175, 0.8)', // gray 'yes': 'rgba(34, 197, 94, 0.8)', // green 'no': 'rgba(239, 68, 68, 0.8)', // red 'maybe': 'rgba(245, 158, 11, 0.8)', // amber }; const lowerLabel = label.toLowerCase(); return fallbackColors[lowerLabel] || 'rgba(245, 158, 11, 0.8)'; } insertAiSpans(keywords, annotationId) { console.log('[SpanManager] insertAiSpans called:', { keywords, annotationId }); if (!keywords || !Array.isArray(keywords) || keywords.length === 0) { console.log('[SpanManager] No keywords to insert, returning early'); return; } this.deleteOneAiSpan(annotationId); const spanOverlays = document.getElementById('span-overlays'); if (!spanOverlays) { console.log('[SpanManager] span-overlays element not found - keyword highlighting only works with span annotation type'); return; } const createdOverlays = []; keywords.forEach((keyword) => { const { label, start, end, text, reasoning } = keyword; if (!this.positioningStrategy || !this.positioningStrategy.isInitialized) { return; } const positions = this.positioningStrategy.getTextPositions(start, end, text); if (!positions || positions.length === 0) { return; } const span = { id: `ai_span_${annotationId}_${start}_${end}_${Date.now()}`, start: start, end: end, text: text, label: label || 'keyword' }; const aiColor = 'rgba(245, 158, 11, 0.8)'; const overlay = this.positioningStrategy.createOverlay(span, positions, { isAiSpan: true, color: aiColor }); if (overlay) { overlay.dataset.aiAnnotationId = annotationId; overlay.title = reasoning || `AI suggested: "${text}"`; spanOverlays.appendChild(overlay); createdOverlays.push(overlay); } }); if (createdOverlays.length > 0) { this.aiSpans.set(annotationId, createdOverlays); } } /** * Clear all AI span highlights */ clearAiSpans() { const spanOverlays = document.getElementById('span-overlays'); if (spanOverlays) { const aiOverlays = spanOverlays.querySelectorAll('.span-overlay-ai'); aiOverlays.forEach(overlay => overlay.remove()); } this.aiSpans.clear(); } /** * Check if AI spans exist for a specific annotation */ inAiSpans(annotationId) { return this.aiSpans.has(annotationId) && this.aiSpans.get(annotationId).length > 0; } /** * Delete AI spans for a specific annotation */ deleteOneAiSpan(annotationId) { const overlays = this.aiSpans.get(annotationId); if (overlays && overlays.length > 0) { overlays.forEach(overlay => { if (overlay && overlay.parentNode) { overlay.remove(); } }); } this.aiSpans.delete(annotationId); } // ==================== CORE SPAN MANAGER METHODS ==================== async fetchCurrentInstanceIdFromServer() { try { const response = await fetch('/api/current_instance'); if (!response.ok) { throw new Error(`Failed to fetch current instance: ${response.status}`); } const data = await response.json(); const serverInstanceId = data.instance_id; if (this.currentInstanceId !== serverInstanceId && this.currentInstanceId !== null) { this.clearAllStateAndOverlays(); } this.currentInstanceId = serverInstanceId; this.lastKnownInstanceId = serverInstanceId; return serverInstanceId; } catch (error) { console.error('[SpanManager] Error fetching current instance ID:', error); return null; } } async initialize() { try { const serverInstanceId = await this.fetchCurrentInstanceIdFromServer(); if (!serverInstanceId) { console.error('[SpanManager] Failed to get server instance ID during initialization'); return false; } // Set up event listeners early (before async strategy init that might block) this.setupEventListeners(); // Check for instance_display span target fields first const spanTargetFields = document.querySelectorAll('.display-field[data-span-target="true"]'); console.log('[SpanManager] init: found', spanTargetFields.length, 'span target fields'); if (spanTargetFields.length > 0) { // Multi-span / instance_display mode for (const field of spanTargetFields) { const fieldKey = field.dataset.fieldKey; const textContent = field.querySelector('.text-content'); console.log('[SpanManager] init: field', fieldKey, 'textContent=', textContent?.id); if (textContent && fieldKey) { // Ensure text content has position: relative for overlay positioning // This is critical: overlays are positioned relative to textContent itself if (!textContent.style.position || textContent.style.position === 'static') { textContent.style.position = 'relative'; } // Create span-overlays container for this field if not present // Append INSIDE textContent so positions are relative to the same container let overlaysEl = textContent.querySelector('.span-overlays-field'); if (!overlaysEl) { overlaysEl = document.createElement('div'); overlaysEl.className = 'span-overlays-field'; overlaysEl.id = `span-overlays-${fieldKey}`; overlaysEl.style.cssText = 'position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 2;'; textContent.appendChild(overlaysEl); } const strategy = new UnifiedPositioningStrategy(textContent); await strategy.initialize(); this.fieldStrategies[fieldKey] = strategy; console.log('[SpanManager] init: strategy ready for', fieldKey); } } // Use the first field as the default positioning strategy const firstKey = Object.keys(this.fieldStrategies)[0]; if (firstKey) { this.positioningStrategy = this.fieldStrategies[firstKey]; } } else { // Legacy single-field mode const textContent = document.getElementById('text-content'); if (textContent) { this.positioningStrategy = new UnifiedPositioningStrategy(textContent); await this.positioningStrategy.initialize(); } } await this.loadSchemas(); await this.loadColors(); this.setupResizeHandler(); this.setupOverlayInteractions(); await this.loadAnnotations(serverInstanceId); // Auto-select the label if a span schema has exactly one label this.autoSelectSingleLabel(); this.isInitialized = true; return true; } catch (error) { console.error('[SpanManager] Initialization failed:', error); this.isInitialized = false; return false; } } async loadColors() { try { const response = await fetch('/api/colors'); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } this.colors = await response.json(); } catch (error) { console.warn('[SpanManager] Error loading colors, using defaults:', error.message); // Fallback colors - visible purple for better visibility const defaultColors = { 'positive': 'rgba(110, 86, 207, 0.15)', // Purple 'negative': 'rgba(239, 68, 68, 0.15)', // Red 'neutral': 'rgba(113, 113, 122, 0.15)', // Gray 'span': 'rgba(110, 86, 207, 0.15)' // Purple }; // Structure as { schemaName: { labelName: color } } to match server format this.colors = { 'sentiment': defaultColors, 'emotion': defaultColors, 'entity': defaultColors, 'topic': defaultColors, 'span': defaultColors }; } } async loadSchemas() { try { const response = await fetch('/api/schemas'); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } this.schemas = await response.json(); if (!this.currentSchema && Object.keys(this.schemas).length > 0) { this.setCurrentSchema(Object.keys(this.schemas)[0], 'loadSchemas'); } return this.schemas; } catch (error) { console.warn('[SpanManager] Error loading schemas:', error.message); return this.extractSchemaFromForms(); } } extractSchemaFromForms() { const spanForms = document.querySelectorAll('.annotation-form.span'); if (spanForms.length > 0) { const firstSpanForm = spanForms[0]; const fieldset = firstSpanForm.querySelector('fieldset'); if (fieldset && fieldset.getAttribute('schema')) { return fieldset.getAttribute('schema'); } } return null; } setupEventListeners() { const textContainer = document.getElementById('instance-text'); const textContent = document.getElementById('text-content'); if (textContainer) { textContainer.addEventListener('mouseup', (e) => this.handleTextSelection(e)); textContainer.addEventListener('keyup', (e) => this.handleTextSelection(e)); } if (textContent) { textContent.addEventListener('mouseup', (e) => this.handleTextSelection(e)); textContent.addEventListener('keyup', (e) => this.handleTextSelection(e)); } // Also listen on instance_display span target fields const spanTargetFields = document.querySelectorAll('.display-field[data-span-target="true"]'); for (const field of spanTargetFields) { field.addEventListener('mouseup', (e) => this.handleTextSelection(e)); field.addEventListener('keyup', (e) => this.handleTextSelection(e)); } // Listen for clicks outside spans to clear discontinuous mode document.addEventListener('click', (e) => { // If clicking outside span annotation areas, clear discontinuous span if (!e.target.closest('.annotation-form.span') && !e.target.closest('#instance-text') && !e.target.closest('.display-field[data-span-target="true"]')) { this.clearActiveDiscontinuousSpan(); } }); document.addEventListener('click', (e) => { if (e.target.classList.contains('span-delete')) { e.stopPropagation(); const annotationId = e.target.closest('.span-highlight').dataset.annotationId; this.deleteSpan(annotationId); } }); } selectLabel(label, schema = null, targetField = null) { this.selectedLabel = label; this.selectedTargetField = targetField || ''; if (schema) { this.setCurrentSchema(schema, 'selectLabel'); } } /** * If a span schema has exactly one label, auto-select it so the user * can immediately start highlighting text without clicking the checkbox first. */ autoSelectSingleLabel() { const spanForms = document.querySelectorAll('.annotation-form.span'); for (const form of spanForms) { const checkboxes = form.querySelectorAll('input[type="checkbox"][for_span="true"]'); if (checkboxes.length === 1 && !checkboxes[0].checked) { checkboxes[0].click(); // triggers changeSpanLabel via onclick } } } getSelectedLabel() { // FIRST: Use the label set by selectLabel() if available // This is set by changeSpanLabel() when a checkbox is clicked, // and is more reliable than querying the DOM if (this.selectedLabel && this.currentSchema) { return this.selectedLabel; } // FALLBACK: Try to find checked span checkbox (for legacy code paths) const checkedCheckbox = document.querySelector('.annotation-form.span input[type="checkbox"]:checked'); if (checkedCheckbox) { const checkboxId = checkedCheckbox.id; const parts = checkboxId.split('_'); if (parts.length >= 2) { // ID format is "schemaName_labelName" - extract both const label = parts[parts.length - 1]; // Schema name is everything before the last underscore // (handles multi-word schema names like "emotion_spans") const schemaName = parts.slice(0, -1).join('_'); // Update currentSchema to match the selected checkbox's schema if (schemaName) { this.setCurrentSchema(schemaName, 'getSelectedLabel'); } return label; } return checkedCheckbox.value; } return this.selectedLabel; } async loadAnnotations(instanceId) { try { const serverInstanceId = await this.fetchCurrentInstanceIdFromServer(); if (serverInstanceId !== instanceId) { instanceId = serverInstanceId; } // Find the text content element - may be legacy #text-content or instance_display fields let textContent = document.getElementById('text-content'); if (!textContent || textContent.closest('[style*="display: none"]')) { // Try instance_display span target fields const firstField = Object.keys(this.fieldStrategies)[0]; if (firstField) { textContent = document.getElementById(`text-content-${firstField}`); } } const existingSpans = textContent ? textContent.querySelectorAll('.span-highlight') : []; const hasServerRenderedSpans = existingSpans.length > 0; const response = await fetch(`/api/spans/${instanceId}`); if (!response.ok) { if (response.status === 404) { this.annotations = { spans: [] }; if (!hasServerRenderedSpans) { this.clearAllStateAndOverlays(); this.renderSpans(); } return Promise.resolve(); } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Get response text first to debug JSON parsing issues const responseText = await response.text(); let responseData; try { responseData = JSON.parse(responseText); } catch (jsonError) { console.error('[SpanManager] JSON parse error for /api/spans/', instanceId); console.error('[SpanManager] Response text (first 500 chars):', responseText.substring(0, 500)); throw new Error(`JSON parse error: ${jsonError.message}`); } this.annotations = responseData; // Only update data-original-text on legacy #text-content, not on per-field elements // Per-field elements already have correct data-original-text from server rendering if (this.annotations && this.annotations.text && textContent) { const hasFieldStrategies = Object.keys(this.fieldStrategies).length > 0; if (!hasFieldStrategies) { const plainText = this.annotations.text.replace(/<[^>]*>/g, '').trim(); textContent.setAttribute('data-original-text', plainText); } } if (this.annotations.spans && this.annotations.spans.length > 0) { const firstSpan = this.annotations.spans[0]; if (firstSpan.schema && !this.currentSchema) { this.setCurrentSchema(firstSpan.schema, 'loadAnnotations'); } } this.renderSpans(); // Load admin keyword highlights after span annotations await this.loadKeywordHighlights(instanceId); } catch (error) { console.error('[SpanManager] Error loading annotations:', error); throw error; } } getSpans() { return this.annotations?.spans || []; } getSpanColor(label) { // Diagnostic logging for color lookup failures if (!this.currentSchema) { console.warn(`[SpanManager] getSpanColor: No currentSchema set for label '${label}'. Using fallback color.`); return 'rgba(110, 86, 207, 0.15)'; } if (!this.colors || Object.keys(this.colors).length === 0) { console.warn(`[SpanManager] getSpanColor: Colors not loaded for label '${label}'. Using fallback color.`); return 'rgba(110, 86, 207, 0.15)'; } if (!this.colors[this.currentSchema]) { console.warn(`[SpanManager] getSpanColor: Schema '${this.currentSchema}' not found in colors. Available schemas: ${Object.keys(this.colors).join(', ')}. Using fallback color.`); return 'rgba(110, 86, 207, 0.15)'; } const schemaColors = this.colors[this.currentSchema]; if (!schemaColors[label]) { console.warn(`[SpanManager] getSpanColor: Label '${label}' not found in schema '${this.currentSchema}'. Available labels: ${Object.keys(schemaColors).join(', ')}. Using fallback color.`); return 'rgba(110, 86, 207, 0.15)'; } const color = schemaColors[label]; if (color.startsWith('(')) { return `rgba${color.replace(')', ', 0.15)')}`; } return color; } clearAllStateAndOverlays() { // Clear legacy overlay container const spanOverlays = document.getElementById('span-overlays'); if (spanOverlays) { const regularOverlays = spanOverlays.querySelectorAll('.span-overlay-pure:not(.span-overlay-ai)'); regularOverlays.forEach(overlay => overlay.remove()); } // Clear per-field overlay containers (multi-span mode) for (const fieldKey of Object.keys(this.fieldStrategies)) { const fieldOverlays = document.getElementById(`span-overlays-${fieldKey}`); if (fieldOverlays) { const regularOverlays = fieldOverlays.querySelectorAll('.span-overlay-pure:not(.span-overlay-ai)'); regularOverlays.forEach(overlay => overlay.remove()); } } this.annotations = { spans: [] }; // Don't clear currentSchema - keep it for consistency } renderSpans() { const hasFieldStrategies = Object.keys(this.fieldStrategies).length > 0; if (hasFieldStrategies) { // Multi-field mode: clear and render per-field for (const fieldKey of Object.keys(this.fieldStrategies)) { const overlaysEl = document.getElementById(`span-overlays-${fieldKey}`); if (overlaysEl) { const regularOverlays = overlaysEl.querySelectorAll('.span-overlay-pure:not(.span-overlay-ai)'); regularOverlays.forEach(overlay => overlay.remove()); } } const spans = this.getSpans(); if (!spans || spans.length === 0) return; const sortedSpans = [...spans].sort((a, b) => a.start - b.start); sortedSpans.forEach((span, index) => { const fieldKey = span.target_field || ''; const strategy = this.fieldStrategies[fieldKey] || this.positioningStrategy; const overlaysEl = document.getElementById(`span-overlays-${fieldKey}`) || document.getElementById('span-overlays'); if (strategy && overlaysEl) { this.renderSpanOverlay(span, index, null, overlaysEl, strategy); } }); } else { // Legacy single-field mode const textContent = document.getElementById('text-content'); const spanOverlays = document.getElementById('span-overlays'); if (!textContent || !spanOverlays) { return; } // Clear existing regular overlays (preserve AI spans) const regularOverlays = spanOverlays.querySelectorAll('.span-overlay-pure:not(.span-overlay-ai)'); regularOverlays.forEach(overlay => overlay.remove()); const spans = this.getSpans(); if (!spans || spans.length === 0) { return; } const sortedSpans = [...spans].sort((a, b) => a.start - b.start); sortedSpans.forEach((span, index) => { this.renderSpanOverlay(span, index, textContent, spanOverlays); }); } } renderSpanOverlay(span, layerIndex, textContent, spanOverlays, strategy = null) { const activeStrategy = strategy || this.positioningStrategy; if (!activeStrategy || !activeStrategy.isInitialized) { return; } // Use getPositionsFromOffsets (offset-based) instead of getTextPositions (text-search-based) // This is critical for multi-field mode where the API might return text from the wrong field const positions = activeStrategy.getPositionsFromOffsets(span.start, span.end); if (!positions || positions.length === 0) { return; } const color = this.getSpanColor(span.label); const overlay = activeStrategy.createOverlay(span, positions, { isAiSpan: false, color: color }); if (overlay) { spanOverlays.appendChild(overlay); } } handleTextSelection(event) { const selection = window.getSelection(); console.warn('[SpanManager] handleTextSelection ENTRY: rangeCount=' + selection.rangeCount + ' isCollapsed=' + selection.isCollapsed + ' selectedLabel=' + this.selectedLabel + ' currentSchema=' + this.currentSchema); if (!selection.rangeCount || selection.isCollapsed) { return; } const selectedLabel = this.getSelectedLabel(); if (!selectedLabel) { console.warn('[SpanManager] handleTextSelection: no selectedLabel, returning'); return; } // Check if Ctrl/Cmd key is held for discontinuous span mode const isDiscontinuousMode = event && (event.ctrlKey || event.metaKey); // Check if discontinuous spans are allowed for current schema const schemaForm = document.querySelector(`.annotation-form.span[data-allow-discontinuous="true"]`); const allowDiscontinuous = schemaForm !== null; // Detect which field the selection is in and pick the right positioning strategy let targetField = this.selectedTargetField || ''; let strategy = this.positioningStrategy; let overlaysContainer = document.getElementById('span-overlays'); const hasFieldStrategies = Object.keys(this.fieldStrategies).length > 0; console.warn('[SpanManager] handleTextSelection: fieldStrategies=', Object.keys(this.fieldStrategies), 'selectedLabel=', selectedLabel); if (selection.rangeCount > 0) { const startNode = selection.getRangeAt(0).startContainer; const el = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : startNode; const textContentEl = el ? el.closest('[id^="text-content-"]') : null; console.warn('[SpanManager] handleTextSelection: textContentEl=', textContentEl?.id, 'el=', el?.id || el?.className); if (textContentEl) { const fieldKey = textContentEl.id.replace('text-content-', ''); if (fieldKey && this.fieldStrategies[fieldKey]) { targetField = fieldKey; strategy = this.fieldStrategies[fieldKey]; overlaysContainer = document.getElementById(`span-overlays-${fieldKey}`); } } } if (!strategy || !strategy.isInitialized) { console.warn('[SpanManager] handleTextSelection: strategy not ready, targetField=' + targetField); return; } console.warn('[SpanManager] handleTextSelection: using strategy for field=' + targetField + ' container=' + (strategy.container ? strategy.container.id : 'NULL') + ' overlays=' + (overlaysContainer ? overlaysContainer.id : 'NULL')); // Handle discontinuous span extension (Ctrl/Cmd+click with existing active span) if (isDiscontinuousMode && allowDiscontinuous && this.activeDiscontinuousSpan) { // Only allow extending if same label and schema if (this.activeDiscontinuousSpan.label === selectedLabel && this.activeDiscontinuousSpan.schema === this.currentSchema) { this.addDiscontinuousPart(selection, strategy, overlaysContainer); selection.removeAllRanges(); return; } } // Get color BEFORE creating the overlay so it's created with the correct color const color = this.getSpanColor(selectedLabel); // Pass color to createSpanFromSelection so overlay is created with correct color const result = strategy.createSpanFromSelection(selection, { color: color }); if (!result) { console.warn('[SpanManager] handleTextSelection: createSpanFromSelection returned null for field=' + targetField); return; } const { span, overlay } = result; span.label = selectedLabel; span.schema = this.currentSchema; span.target_field = targetField; span.additional_parts = []; // Initialize for discontinuous support // Generate deterministic ID matching server format: {schema}_{label}_{start}_{end} // This ensures span IDs are stable across page reloads const oldId = span.id; span.id = `${this.currentSchema}_${selectedLabel}_${span.start}_${span.end}`; console.log(`[SpanManager] Generated deterministic span ID: ${span.id} (was ${oldId})`); if (overlay) { // Update overlay's data-annotation-id to match the new deterministic ID overlay.dataset.annotationId = span.id; // Update the label text to match the selected label const label = overlay.querySelector('.span-label'); if (label) { label.textContent = selectedLabel; } // Add discontinuous indicator if enabled if (allowDiscontinuous) { overlay.classList.add('discontinuous-enabled'); overlay.dataset.discontinuousEnabled = 'true'; } // Append the overlay to the correct container if (overlaysContainer) { overlaysContainer.appendChild(overlay); console.warn('[SpanManager] handleTextSelection: overlay appended to ' + overlaysContainer.id + ' for field=' + targetField); } else { console.warn('[SpanManager] handleTextSelection: no overlaysContainer for field=' + targetField); } } // Add span to local state if (!this.annotations.spans) { this.annotations.spans = []; } this.annotations.spans.push(span); // If discontinuous mode is enabled, set this as the active span for extension if (allowDiscontinuous && isDiscontinuousMode) { this.activeDiscontinuousSpan = span; console.log('[SpanManager] Set active discontinuous span:', span.id); } else { // Clear active span if not in discontinuous mode this.activeDiscontinuousSpan = null; } this.saveSpan(span); selection.removeAllRanges(); } /** * Add an additional part to the active discontinuous span. * @param {Selection} selection - The current text selection * @param {UnifiedPositioningStrategy} strategy - The positioning strategy to use * @param {Element} overlaysContainer - The container for span overlays */ addDiscontinuousPart(selection, strategy, overlaysContainer) { if (!this.activeDiscontinuousSpan) { console.warn('[SpanManager] addDiscontinuousPart: no active discontinuous span'); return; } // Get the offsets for the new selection const offsets = strategy.getOffsetsFromSelection(selection); if (!offsets) { console.warn('[SpanManager] addDiscontinuousPart: could not get offsets'); return; } const selectedText = selection.toString().trim(); const newPart = { start: offsets.start, end: offsets.end, text: selectedText }; // Check for overlap with existing parts const allParts = [ { start: this.activeDiscontinuousSpan.start, end: this.activeDiscontinuousSpan.end }, ...(this.activeDiscontinuousSpan.additional_parts || []) ]; for (const part of allParts) { if (newPart.start < part.end && newPart.end > part.start) { console.warn('[SpanManager] addDiscontinuousPart: new part overlaps with existing part'); return; } } // Add the new part if (!this.activeDiscontinuousSpan.additional_parts) { this.activeDiscontinuousSpan.additional_parts = []; } this.activeDiscontinuousSpan.additional_parts.push(newPart); // Sort parts by start position this.activeDiscontinuousSpan.additional_parts.sort((a, b) => a.start - b.start); console.log('[SpanManager] Added discontinuous part:', newPart, 'to span:', this.activeDiscontinuousSpan.id); // Create overlay for the new part const color = this.getSpanColor(this.activeDiscontinuousSpan.label); const positions = strategy.getPositionsFromOffsets(newPart.start, newPart.end); if (positions && positions.length > 0) { const partOverlay = this.createDiscontinuousPartOverlay( this.activeDiscontinuousSpan, newPart, positions, color ); if (partOverlay && overlaysContainer) { overlaysContainer.appendChild(partOverlay); } } // Save the updated span this.saveSpanWithParts(this.activeDiscontinuousSpan); } /** * Create an overlay for a discontinuous span part. * Uses a dashed border to visually connect it to the parent span. */ createDiscontinuousPartOverlay(parentSpan, part, positions, color) { const HORIZONTAL_PADDING = 3; const VERTICAL_PADDING = 2; const overlay = document.createElement('div'); overlay.className = 'span-overlay-pure discontinuous-part'; overlay.dataset.annotationId = parentSpan.id; overlay.dataset.parentSpanId = parentSpan.id; overlay.dataset.partStart = part.start; overlay.dataset.partEnd = part.end; overlay.dataset.label = parentSpan.label; overlay.dataset.isDiscontinuousPart = 'true'; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = OVERLAY_Z_INDEX.USER_SPAN; positions.forEach((pos) => { const segment = document.createElement('div'); segment.className = 'span-highlight-segment discontinuous-segment'; segment.style.position = 'absolute'; segment.style.left = `${pos.x - HORIZONTAL_PADDING}px`; segment.style.top = `${pos.y - VERTICAL_PADDING}px`; segment.style.width = `${pos.width + 2 * HORIZONTAL_PADDING}px`; segment.style.height = `${pos.height + 2 * VERTICAL_PADDING}px`; segment.style.backgroundColor = color; segment.style.border = `2px dashed ${color.replace('0.15', '0.8').replace('0.3', '0.8')}`; segment.style.borderRadius = '4px'; segment.style.pointerEvents = 'none'; overlay.appendChild(segment); }); // Add a small label indicating this is part of a discontinuous span if (positions.length > 0) { const label = document.createElement('div'); label.className = 'span-label discontinuous-label'; label.textContent = `+ ${parentSpan.label}`; label.style.position = 'absolute'; label.style.left = `${positions[0].x - HORIZONTAL_PADDING}px`; label.style.top = `${Math.max(0, positions[0].y - VERTICAL_PADDING - 18)}px`; label.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; label.style.color = 'white'; label.style.padding = '1px 4px'; label.style.borderRadius = '3px'; label.style.fontSize = '10px'; label.style.fontWeight = '500'; label.style.whiteSpace = 'nowrap'; label.style.pointerEvents = 'auto'; overlay.appendChild(label); } return overlay; } /** * Save a span with additional parts (discontinuous span). */ async saveSpanWithParts(span) { try { const postData = { type: "span", schema: span.schema || this.currentSchema, state: [{ name: span.label, start: span.start, end: span.end, title: span.label, value: 1, target_field: span.target_field || '', additional_parts: span.additional_parts || [], span_id: span.id // Send the deterministic ID to server }], instance_id: this.currentInstanceId }; const response = await fetch('/updateinstance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postData) }); if (response.ok) { console.log('[SpanManager] Saved discontinuous span with parts:', span.additional_parts); // Don't reload annotations immediately to preserve UI state during discontinuous mode } else { console.error('[SpanManager] Failed to save discontinuous span:', await response.text()); } } catch (error) { console.error('[SpanManager] Error saving discontinuous span:', error); } } /** * Clear the active discontinuous span (e.g., when clicking elsewhere or selecting a different label). */ clearActiveDiscontinuousSpan() { if (this.activeDiscontinuousSpan) { console.log('[SpanManager] Clearing active discontinuous span:', this.activeDiscontinuousSpan.id); this.activeDiscontinuousSpan = null; } } async saveSpan(span) { try { const spanState = { name: span.label, start: span.start, end: span.end, title: span.label, value: 1, target_field: span.target_field || '', span_id: span.id // Send the deterministic ID to server }; // Include additional_parts for discontinuous spans if (span.additional_parts && span.additional_parts.length > 0) { spanState.additional_parts = span.additional_parts; } const postData = { type: "span", schema: span.schema || this.currentSchema, state: [spanState], instance_id: this.currentInstanceId }; const response = await fetch('/updateinstance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postData) }); if (response.ok) { await this.loadAnnotations(this.currentInstanceId); } else { console.error('[SpanManager] Failed to save span:', await response.text()); } } catch (error) { console.error('[SpanManager] Error saving span:', error); } } async deleteSpan(spanId) { const span = this.annotations.spans?.find(s => s.id === spanId); // Immediately remove the overlay from DOM for instant visual feedback // This ensures overlay is removed even if server reload has issues // Search in all possible overlay containers (main and field-specific) const selector = `.span-overlay-pure[data-annotation-id="${spanId}"]`; // Remove from main span-overlays container const spanOverlays = document.getElementById('span-overlays'); if (spanOverlays) { const overlayToRemove = spanOverlays.querySelector(selector); if (overlayToRemove) { overlayToRemove.remove(); } } // Also search in field-specific overlay containers document.querySelectorAll('[id^="span-overlays-"]').forEach(container => { const overlayToRemove = container.querySelector(selector); if (overlayToRemove) { overlayToRemove.remove(); } }); // Also search anywhere in the document as a fallback const anyOverlay = document.querySelector(selector); if (anyOverlay) { anyOverlay.remove(); } // Also remove server-rendered inline span-highlight elements // These are rendered by render_span_annotations() on the server const inlineSelector = `.span-highlight[data-annotation-id="${spanId}"]`; document.querySelectorAll(inlineSelector).forEach(inlineSpan => { // Unwrap the span: replace it with its text content const parent = inlineSpan.parentNode; while (inlineSpan.firstChild) { parent.insertBefore(inlineSpan.firstChild, inlineSpan); } inlineSpan.remove(); }); // Also remove from local state immediately if (this.annotations.spans) { this.annotations.spans = this.annotations.spans.filter(s => s.id !== spanId); } if (!span) { // Even if span not found in state, we already removed the overlay visually return; } try { const postData = { type: "span", schema: span.schema || this.currentSchema, state: [{ name: span.label, start: span.start, end: span.end, title: span.label, value: null }], instance_id: this.currentInstanceId }; const response = await fetch('/updateinstance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postData) }); if (response.ok) { // Reload to sync with server state await this.loadAnnotations(this.currentInstanceId); } else { console.error('[SpanManager] Failed to delete span:', await response.text()); // Reload anyway to get correct state from server await this.loadAnnotations(this.currentInstanceId); } } catch (error) { console.error('[SpanManager] Error deleting span:', error); // Reload to ensure UI matches server state await this.loadAnnotations(this.currentInstanceId); } } onInstanceChange(newInstanceId) { // Clear AI spans and keyword highlights on instance change this.clearAiSpans(); this.clearKeywordHighlights(); if (newInstanceId && newInstanceId !== this.currentInstanceId) { this.clearAllStateAndOverlays(); this.currentInstanceId = newInstanceId; this.loadAnnotations(newInstanceId); } } calculateOverlapDepths(spans) { if (!spans || spans.length === 0) return []; const result = spans.map(span => ({ span, depth: 0, heightMultiplier: 1.0 })); for (let i = 0; i < spans.length; i++) { let maxOverlap = 0; for (let j = 0; j < i; j++) { if (spans[j].end > spans[i].start && spans[j].start < spans[i].end) { maxOverlap = Math.max(maxOverlap, result[j].depth + 1); } } result[i].depth = maxOverlap; result[i].heightMultiplier = 1.0 / (maxOverlap + 1); } return result; } applyOverlapStyling(spanElements, overlapData) { spanElements.forEach((element, index) => { if (overlapData[index]) { const { depth, heightMultiplier } = overlapData[index]; element.style.setProperty('--overlap-depth', depth); element.style.setProperty('--height-multiplier', heightMultiplier); } }); } // ==================== KEYWORD HIGHLIGHT METHODS ==================== /** * Load admin-defined keyword highlights for the current instance. * These are displayed using the same visual system as AI keyword suggestions * (bounding boxes around keywords). */ async loadKeywordHighlights(instanceId) { try { const response = await fetch(`/api/keyword_highlights/${instanceId}`); if (!response.ok) { if (response.status === 404) { return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const keywords = data.keywords || []; if (keywords.length === 0) { return; } this.insertKeywordHighlights(keywords); } catch (error) { console.error('[SpanManager] Error loading keyword highlights:', error); } } /** * Insert admin keyword highlights using the same visual system as AI spans. * @param {Array} keywords - Array of keyword objects with {label, start, end, text, reasoning, schema, color} */ insertKeywordHighlights(keywords) { if (!keywords || !Array.isArray(keywords) || keywords.length === 0) { return; } // Clear any existing admin keyword highlights this.clearKeywordHighlights(); const spanOverlays = document.getElementById('span-overlays'); if (!spanOverlays) { return; } const createdOverlays = []; keywords.forEach((keyword) => { const { label, start, end, text, reasoning, schema, color } = keyword; if (!this.positioningStrategy || !this.positioningStrategy.isInitialized) { return; } // Use getPositionsFromOffsets() which respects the provided offsets const positions = this.positioningStrategy.getPositionsFromOffsets(start, end); if (!positions || positions.length === 0) { console.warn('[SpanManager] No positions found for admin keyword:', { start, end, text }); return; } const span = { id: `keyword_${start}_${end}_${Date.now()}`, start: start, end: end, text: text, label: label || 'keyword' }; const keywordColor = color || 'rgba(245, 158, 11, 0.8)'; const overlay = this.positioningStrategy.createOverlay(span, positions, { isAiSpan: true, // Use bordered style color: keywordColor }); if (overlay) { overlay.dataset.keywordHighlight = 'true'; overlay.dataset.schema = schema || ''; overlay.dataset.label = label || ''; overlay.title = reasoning || `Keyword: "${text}" → ${label}`; overlay.classList.add('keyword-highlight-overlay'); // Admin keywords use a lower z-index than AI keywords overlay.style.zIndex = OVERLAY_Z_INDEX.ADMIN_KEYWORD; spanOverlays.appendChild(overlay); createdOverlays.push(overlay); } }); this.keywordHighlights = createdOverlays; } /** * Clear all admin keyword highlight overlays. */ clearKeywordHighlights() { const spanOverlays = document.getElementById('span-overlays'); if (spanOverlays) { const keywordOverlays = spanOverlays.querySelectorAll('.keyword-highlight-overlay'); keywordOverlays.forEach(overlay => overlay.remove()); } this.keywordHighlights = []; } // ==================== RESIZE HANDLING ==================== /** * Setup resize handler to reposition overlays when window resizes. * Uses debouncing to avoid performance issues during resize. */ setupResizeHandler() { let resizeTimeout = null; const DEBOUNCE_MS = 150; const handleResize = () => { if (resizeTimeout) { clearTimeout(resizeTimeout); } resizeTimeout = setTimeout(() => { this.repositionAllOverlays(); }, DEBOUNCE_MS); }; window.addEventListener('resize', handleResize); // Also observe container size changes (for dynamic layouts) if (typeof ResizeObserver !== 'undefined') { const instanceText = document.getElementById('instance-text'); if (instanceText) { const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(instanceText); } } spanCoreDebugLog('[SpanManager] Resize handler initialized'); } /** * Reposition all overlays based on current text positions. * Called after resize to ensure overlays stay aligned with text. */ repositionAllOverlays() { spanCoreDebugLog('[SpanManager] Repositioning all overlays'); // Re-render user span overlays this.renderSpans(); // Re-render admin keyword highlights if (this.currentInstanceId) { this.loadKeywordHighlights(this.currentInstanceId); } // Clear AI keyword overlays on resize since they're temporary // User can re-click the keyword button to regenerate this.clearAiSpans(); } // ==================== UNIFIED OVERLAY INTERACTIONS ==================== /** * Setup unified interaction handlers for all overlay types. * Provides consistent hover effects and click behavior. */ setupOverlayInteractions() { const spanOverlays = document.getElementById('span-overlays'); if (!spanOverlays) return; // Use event delegation for hover effects spanOverlays.addEventListener('mouseenter', (e) => { const segment = e.target.closest('.span-highlight-segment, .span-segment'); if (segment) { this.handleSegmentHover(segment, true); } }, true); spanOverlays.addEventListener('mouseleave', (e) => { const segment = e.target.closest('.span-highlight-segment, .span-segment'); if (segment) { this.handleSegmentHover(segment, false); } }, true); spanCoreDebugLog('[SpanManager] Overlay interactions initialized'); } /** * Handle hover state for overlay segments. * @param {Element} segment - The segment element * @param {boolean} isHovering - Whether mouse is entering or leaving */ handleSegmentHover(segment, isHovering) { const overlay = segment.closest('.span-overlay, .span-overlay-pure, .span-overlay-ai, .ai-keyword-overlay, .keyword-highlight-overlay'); if (!overlay) return; if (isHovering) { // Highlight all segments of the same overlay overlay.querySelectorAll('.span-highlight-segment, .span-segment').forEach(seg => { seg.style.filter = 'brightness(0.85)'; }); // Show tooltip if available (for AI/keyword overlays without controls) const tooltipText = overlay.title || overlay.dataset.label; const hasControls = overlay.querySelector('.span-controls'); if (tooltipText && !hasControls) { this.showOverlayTooltip(segment, tooltipText); } } else { // Remove highlight overlay.querySelectorAll('.span-highlight-segment, .span-segment').forEach(seg => { seg.style.filter = ''; }); this.hideOverlayTooltip(); } } /** * Show tooltip near a segment. * @param {Element} segment - The segment to position tooltip near * @param {string} text - The tooltip text */ showOverlayTooltip(segment, text) { const spanOverlays = document.getElementById('span-overlays'); if (!spanOverlays) return; let tooltip = document.getElementById('overlay-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.id = 'overlay-tooltip'; tooltip.className = 'overlay-tooltip'; tooltip.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.85); color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: ${OVERLAY_Z_INDEX.TOOLTIP}; white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis; `; spanOverlays.appendChild(tooltip); } const rect = segment.getBoundingClientRect(); const containerRect = document.getElementById('instance-text').getBoundingClientRect(); tooltip.textContent = text; tooltip.style.left = `${rect.left - containerRect.left}px`; tooltip.style.top = `${Math.max(0, rect.top - containerRect.top - 28)}px`; tooltip.style.display = 'block'; } /** * Hide the overlay tooltip. */ hideOverlayTooltip() { const tooltip = document.getElementById('overlay-tooltip'); if (tooltip) { tooltip.style.display = 'none'; } } } // Initialize global span manager window.spanManager = new SpanManager(); /** * Initialize the span manager. * Called once when DOM is ready, with a single retry fallback. */ function initializeSpanManager() { if (window.spanManager && !window.spanManager.isInitialized) { window.spanManager.initialize().catch((error) => { console.error('[SpanManager] Initialization failed:', error); }); } } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeSpanManager); } else { // DOM already loaded, initialize immediately initializeSpanManager(); } // Single retry fallback after 1 second // Handles edge cases where text-content isn't populated yet on initial load // (e.g., content loaded via AJAX after DOMContentLoaded) setTimeout(() => { if (window.spanManager && !window.spanManager.isInitialized) { console.debug('[SpanManager] Retry initialization after 1s timeout'); initializeSpanManager(); } }, 1000); // Export for testing if (typeof module !== 'undefined' && module.exports) { module.exports = { SpanManager, UnifiedPositioningStrategy, getFontMetrics }; }