Spaces:
Paused
Paused
| /** | |
| * 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<annotationId, Array<overlayElement>> | |
| // 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 }; | |
| } | |