| |
| |
| |
| |
| |
|
|
| |
| function spanCoreDebugLog(...args) { |
| if (window.config && window.config.debug) { |
| console.log(...args); |
| } |
| } |
|
|
| function spanCoreDebugWarn(...args) { |
| if (window.config && window.config.debug) { |
| console.warn(...args); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| const OVERLAY_Z_INDEX = { |
| |
| TEXT_CONTENT: 1, |
| OVERLAY_CONTAINER: 2, |
|
|
| |
| ADMIN_KEYWORD: 100, |
| AI_KEYWORD: 110, |
| USER_SPAN: 120, |
|
|
| |
| SPAN_CONTROLS: 200, |
| TOOLTIP: 300 |
| }; |
|
|
| |
| |
| |
| 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 |
| } |
| }; |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| |
| if (this.container && ( |
| this.container.closest('.code-display') || |
| this.container.closest('.code-simple') || |
| this.container.querySelector('pre') |
| )) { |
| |
| return text.replace(/[ \t]+/g, ' ').trim(); |
| } |
| |
| 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; |
| } |
|
|
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| 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; |
|
|
| |
| 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) { |
| |
| if (node.id === 'span-overlays' || node.classList.contains('span-overlays-field')) { |
| return; |
| } |
| for (const child of node.childNodes) { |
| collectTextNodes(child); |
| } |
| } |
| }; |
| collectTextNodes(this.container); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| if (absoluteStart === null && startContainer.nodeType === Node.ELEMENT_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; |
| } |
|
|
| |
| |
| |
| const positions = this.getPositionsFromOffsets(start, end); |
| if (!positions || positions.length === 0) { |
| return null; |
| } |
|
|
| |
| 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 = { |
| |
| id: `span_${start}_${end}_${Date.now()}`, |
| start: start, |
| end: end, |
| text: text, |
| label: 'unknown', |
| color: null |
| }; |
|
|
| |
| span._tempPositions = { start, end }; |
|
|
| |
| const overlay = this.createOverlay(span, positions, options); |
| if (!overlay) { |
| return null; |
| } |
|
|
| return { span, overlay, positions }; |
| } |
|
|
| getTextPositions(start, end, text) { |
| |
| |
| 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(); |
|
|
| |
| 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) { |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| getPositionsFromOffsets(start, end) { |
| |
| const textElement = this.container || document.getElementById('text-content'); |
| if (!textElement) { |
| console.warn('[SpanCore] getPositionsFromOffsets: text-content element not found'); |
| return null; |
| } |
|
|
| |
| 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) { |
| |
| 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; |
| } |
|
|
| |
| let startNode = null, startOffset = 0; |
| let endNode = null, endOffset = 0; |
|
|
| for (const tn of textNodes) { |
| |
| if (startNode === null && start >= tn.start && start < tn.end) { |
| startNode = tn.node; |
| startOffset = start - tn.start; |
| } |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| 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; |
|
|
| |
| const HORIZONTAL_PADDING = 3; |
| const VERTICAL_PADDING = 2; |
|
|
| 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'; |
| } |
| |
| 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'; |
| |
| |
| |
| 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'; |
| |
| 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) { |
| |
| 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) { |
| |
| |
| const segmentLeft = positions[0].x - HORIZONTAL_PADDING; |
| const segmentTop = positions[0].y - VERTICAL_PADDING; |
|
|
| |
| |
| const controlsContainer = document.createElement('div'); |
| controlsContainer.className = 'span-controls'; |
| controlsContainer.style.position = 'absolute'; |
| controlsContainer.style.left = `${segmentLeft}px`; |
| |
| 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'; |
|
|
| |
| 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; |
| |
| 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'; |
| |
| deleteBtn.style.position = 'static'; |
| deleteBtn.style.top = 'auto'; |
| deleteBtn.style.right = 'auto'; |
| deleteBtn.style.left = 'auto'; |
| deleteBtn.onclick = (e) => { |
| e.stopPropagation(); |
| |
| const overlayElement = e.target.closest('.span-overlay-pure'); |
| if (overlayElement) { |
| overlayElement.remove(); |
| } |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| 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 = {}; |
|
|
| |
| this.fieldStrategies = {}; |
|
|
| |
| this.aiSpans = new Map(); |
|
|
| |
| this.keywordHighlights = []; |
|
|
| |
| this.discontinuousMode = false; |
| this.activeDiscontinuousSpan = null; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| setCurrentSchema(schema, source = 'unknown') { |
| if (!schema) { |
| console.warn(`[SpanManager] setCurrentSchema called with empty schema from ${source}`); |
| return false; |
| } |
|
|
| |
| 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(', ')}`); |
| |
| } |
|
|
| const oldSchema = this.currentSchema; |
| this.currentSchema = schema; |
|
|
| if (oldSchema !== schema) { |
| console.debug(`[SpanManager] Schema changed: '${oldSchema}' -> '${schema}' (source: ${source})`); |
| } |
|
|
| return true; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| const positions = this.positioningStrategy.getPositionsFromOffsets(start, end); |
| if (!positions || positions.length === 0) { |
| console.warn('[SpanManager] No positions found for highlight:', { start, end, text }); |
| return; |
| } |
|
|
| |
| const color = this.getAiKeywordColor(label, schema); |
|
|
| const span = { |
| id: `ai_keyword_${annotationId}_${start}_${end}_${Date.now()}`, |
| start: start, |
| end: end, |
| text: text, |
| label: label |
| }; |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| |
| createBorderedOverlay(span, positions, color) { |
| |
| 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'; |
| |
| 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; |
| } |
|
|
| |
| |
| |
| getAiKeywordColor(label, schemaName) { |
| |
| if (this.colors && schemaName && this.colors[schemaName]) { |
| const schemaColors = this.colors[schemaName]; |
| |
| if (schemaColors[label]) { |
| const color = schemaColors[label]; |
| if (color.startsWith('(')) { |
| return `rgba${color.replace(')', ', 0.8)')}`; |
| } |
| return color; |
| } |
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| const fallbackColors = { |
| 'positive': 'rgba(34, 197, 94, 0.8)', |
| 'negative': 'rgba(239, 68, 68, 0.8)', |
| 'neutral': 'rgba(156, 163, 175, 0.8)', |
| 'yes': 'rgba(34, 197, 94, 0.8)', |
| 'no': 'rgba(239, 68, 68, 0.8)', |
| 'maybe': 'rgba(245, 158, 11, 0.8)', |
| }; |
|
|
| 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); |
| } |
| } |
|
|
| |
| |
| |
| clearAiSpans() { |
| const spanOverlays = document.getElementById('span-overlays'); |
| if (spanOverlays) { |
| const aiOverlays = spanOverlays.querySelectorAll('.span-overlay-ai'); |
| aiOverlays.forEach(overlay => overlay.remove()); |
| } |
| this.aiSpans.clear(); |
| } |
|
|
| |
| |
| |
| inAiSpans(annotationId) { |
| return this.aiSpans.has(annotationId) && this.aiSpans.get(annotationId).length > 0; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
|
|
| 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; |
| } |
|
|
| |
| this.setupEventListeners(); |
|
|
| |
| const spanTargetFields = document.querySelectorAll('.display-field[data-span-target="true"]'); |
| console.log('[SpanManager] init: found', spanTargetFields.length, 'span target fields'); |
| if (spanTargetFields.length > 0) { |
| |
| 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) { |
| |
| |
| if (!textContent.style.position || textContent.style.position === 'static') { |
| textContent.style.position = 'relative'; |
| } |
|
|
| |
| |
| 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); |
| } |
| } |
| |
| const firstKey = Object.keys(this.fieldStrategies)[0]; |
| if (firstKey) { |
| this.positioningStrategy = this.fieldStrategies[firstKey]; |
| } |
| } else { |
| |
| 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); |
|
|
| |
| 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); |
| |
| const defaultColors = { |
| 'positive': 'rgba(110, 86, 207, 0.15)', |
| 'negative': 'rgba(239, 68, 68, 0.15)', |
| 'neutral': 'rgba(113, 113, 122, 0.15)', |
| 'span': 'rgba(110, 86, 207, 0.15)' |
| }; |
| |
| 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)); |
| } |
|
|
| |
| 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)); |
| } |
|
|
| |
| document.addEventListener('click', (e) => { |
| |
| 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'); |
| } |
| } |
|
|
| |
| |
| |
| |
| 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(); |
| } |
| } |
| } |
|
|
| getSelectedLabel() { |
| |
| |
| |
| if (this.selectedLabel && this.currentSchema) { |
| return this.selectedLabel; |
| } |
|
|
| |
| 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) { |
| |
| const label = parts[parts.length - 1]; |
| |
| |
| const schemaName = parts.slice(0, -1).join('_'); |
|
|
| |
| 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; |
| } |
|
|
| |
| let textContent = document.getElementById('text-content'); |
| if (!textContent || textContent.closest('[style*="display: none"]')) { |
| |
| 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}`); |
| } |
|
|
| |
| 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; |
|
|
| |
| |
| 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(); |
|
|
| |
| await this.loadKeywordHighlights(instanceId); |
|
|
| } catch (error) { |
| console.error('[SpanManager] Error loading annotations:', error); |
| throw error; |
| } |
| } |
|
|
| getSpans() { |
| return this.annotations?.spans || []; |
| } |
|
|
| getSpanColor(label) { |
| |
| 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() { |
| |
| const spanOverlays = document.getElementById('span-overlays'); |
| if (spanOverlays) { |
| const regularOverlays = spanOverlays.querySelectorAll('.span-overlay-pure:not(.span-overlay-ai)'); |
| regularOverlays.forEach(overlay => overlay.remove()); |
| } |
|
|
| |
| 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: [] }; |
| |
| } |
|
|
| renderSpans() { |
| const hasFieldStrategies = Object.keys(this.fieldStrategies).length > 0; |
|
|
| if (hasFieldStrategies) { |
| |
| 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 { |
| |
| const textContent = document.getElementById('text-content'); |
| const spanOverlays = document.getElementById('span-overlays'); |
|
|
| if (!textContent || !spanOverlays) { |
| return; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| const isDiscontinuousMode = event && (event.ctrlKey || event.metaKey); |
|
|
| |
| const schemaForm = document.querySelector(`.annotation-form.span[data-allow-discontinuous="true"]`); |
| const allowDiscontinuous = schemaForm !== null; |
|
|
| |
| 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')); |
|
|
| |
| if (isDiscontinuousMode && allowDiscontinuous && this.activeDiscontinuousSpan) { |
| |
| if (this.activeDiscontinuousSpan.label === selectedLabel && |
| this.activeDiscontinuousSpan.schema === this.currentSchema) { |
| this.addDiscontinuousPart(selection, strategy, overlaysContainer); |
| selection.removeAllRanges(); |
| return; |
| } |
| } |
|
|
| |
| const color = this.getSpanColor(selectedLabel); |
|
|
| |
| 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 = []; |
|
|
| |
| |
| 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) { |
| |
| overlay.dataset.annotationId = span.id; |
| |
| const label = overlay.querySelector('.span-label'); |
| if (label) { |
| label.textContent = selectedLabel; |
| } |
|
|
| |
| if (allowDiscontinuous) { |
| overlay.classList.add('discontinuous-enabled'); |
| overlay.dataset.discontinuousEnabled = 'true'; |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| if (!this.annotations.spans) { |
| this.annotations.spans = []; |
| } |
| this.annotations.spans.push(span); |
|
|
| |
| if (allowDiscontinuous && isDiscontinuousMode) { |
| this.activeDiscontinuousSpan = span; |
| console.log('[SpanManager] Set active discontinuous span:', span.id); |
| } else { |
| |
| this.activeDiscontinuousSpan = null; |
| } |
|
|
| this.saveSpan(span); |
| selection.removeAllRanges(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| addDiscontinuousPart(selection, strategy, overlaysContainer) { |
| if (!this.activeDiscontinuousSpan) { |
| console.warn('[SpanManager] addDiscontinuousPart: no active discontinuous span'); |
| return; |
| } |
|
|
| |
| 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 |
| }; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| if (!this.activeDiscontinuousSpan.additional_parts) { |
| this.activeDiscontinuousSpan.additional_parts = []; |
| } |
| this.activeDiscontinuousSpan.additional_parts.push(newPart); |
|
|
| |
| this.activeDiscontinuousSpan.additional_parts.sort((a, b) => a.start - b.start); |
|
|
| console.log('[SpanManager] Added discontinuous part:', newPart, 'to span:', this.activeDiscontinuousSpan.id); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| this.saveSpanWithParts(this.activeDiscontinuousSpan); |
| } |
|
|
| |
| |
| |
| |
| 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); |
| }); |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 |
| }], |
| 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); |
| |
| } else { |
| console.error('[SpanManager] Failed to save discontinuous span:', await response.text()); |
| } |
| } catch (error) { |
| console.error('[SpanManager] Error saving discontinuous span:', error); |
| } |
| } |
|
|
| |
| |
| |
| 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 |
| }; |
|
|
| |
| 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); |
|
|
| |
| |
| |
| const selector = `.span-overlay-pure[data-annotation-id="${spanId}"]`; |
|
|
| |
| const spanOverlays = document.getElementById('span-overlays'); |
| if (spanOverlays) { |
| const overlayToRemove = spanOverlays.querySelector(selector); |
| if (overlayToRemove) { |
| overlayToRemove.remove(); |
| } |
| } |
|
|
| |
| document.querySelectorAll('[id^="span-overlays-"]').forEach(container => { |
| const overlayToRemove = container.querySelector(selector); |
| if (overlayToRemove) { |
| overlayToRemove.remove(); |
| } |
| }); |
|
|
| |
| const anyOverlay = document.querySelector(selector); |
| if (anyOverlay) { |
| anyOverlay.remove(); |
| } |
|
|
| |
| |
| const inlineSelector = `.span-highlight[data-annotation-id="${spanId}"]`; |
| document.querySelectorAll(inlineSelector).forEach(inlineSpan => { |
| |
| const parent = inlineSpan.parentNode; |
| while (inlineSpan.firstChild) { |
| parent.insertBefore(inlineSpan.firstChild, inlineSpan); |
| } |
| inlineSpan.remove(); |
| }); |
|
|
| |
| if (this.annotations.spans) { |
| this.annotations.spans = this.annotations.spans.filter(s => s.id !== spanId); |
| } |
|
|
| if (!span) { |
| |
| 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) { |
| |
| await this.loadAnnotations(this.currentInstanceId); |
| } else { |
| console.error('[SpanManager] Failed to delete span:', await response.text()); |
| |
| await this.loadAnnotations(this.currentInstanceId); |
| } |
| } catch (error) { |
| console.error('[SpanManager] Error deleting span:', error); |
| |
| await this.loadAnnotations(this.currentInstanceId); |
| } |
| } |
|
|
| onInstanceChange(newInstanceId) { |
| |
| 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); |
| } |
| }); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| |
| insertKeywordHighlights(keywords) { |
| if (!keywords || !Array.isArray(keywords) || keywords.length === 0) { |
| return; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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, |
| 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'); |
| |
| overlay.style.zIndex = OVERLAY_Z_INDEX.ADMIN_KEYWORD; |
| spanOverlays.appendChild(overlay); |
| createdOverlays.push(overlay); |
| } |
| }); |
|
|
| this.keywordHighlights = createdOverlays; |
| } |
|
|
| |
| |
| |
| clearKeywordHighlights() { |
| const spanOverlays = document.getElementById('span-overlays'); |
| if (spanOverlays) { |
| const keywordOverlays = spanOverlays.querySelectorAll('.keyword-highlight-overlay'); |
| keywordOverlays.forEach(overlay => overlay.remove()); |
| } |
|
|
| this.keywordHighlights = []; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| setupResizeHandler() { |
| let resizeTimeout = null; |
| const DEBOUNCE_MS = 150; |
|
|
| const handleResize = () => { |
| if (resizeTimeout) { |
| clearTimeout(resizeTimeout); |
| } |
| resizeTimeout = setTimeout(() => { |
| this.repositionAllOverlays(); |
| }, DEBOUNCE_MS); |
| }; |
|
|
| window.addEventListener('resize', handleResize); |
|
|
| |
| 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'); |
| } |
|
|
| |
| |
| |
| |
| repositionAllOverlays() { |
| spanCoreDebugLog('[SpanManager] Repositioning all overlays'); |
|
|
| |
| this.renderSpans(); |
|
|
| |
| if (this.currentInstanceId) { |
| this.loadKeywordHighlights(this.currentInstanceId); |
| } |
|
|
| |
| |
| this.clearAiSpans(); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| setupOverlayInteractions() { |
| const spanOverlays = document.getElementById('span-overlays'); |
| if (!spanOverlays) return; |
|
|
| |
| 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'); |
| } |
|
|
| |
| |
| |
| |
| |
| 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) { |
| |
| overlay.querySelectorAll('.span-highlight-segment, .span-segment').forEach(seg => { |
| seg.style.filter = 'brightness(0.85)'; |
| }); |
|
|
| |
| const tooltipText = overlay.title || overlay.dataset.label; |
| const hasControls = overlay.querySelector('.span-controls'); |
| if (tooltipText && !hasControls) { |
| this.showOverlayTooltip(segment, tooltipText); |
| } |
| } else { |
| |
| overlay.querySelectorAll('.span-highlight-segment, .span-segment').forEach(seg => { |
| seg.style.filter = ''; |
| }); |
| this.hideOverlayTooltip(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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'; |
| } |
|
|
| |
| |
| |
| hideOverlayTooltip() { |
| const tooltip = document.getElementById('overlay-tooltip'); |
| if (tooltip) { |
| tooltip.style.display = 'none'; |
| } |
| } |
| } |
|
|
| |
| window.spanManager = new SpanManager(); |
|
|
| |
| |
| |
| |
| function initializeSpanManager() { |
| if (window.spanManager && !window.spanManager.isInitialized) { |
| window.spanManager.initialize().catch((error) => { |
| console.error('[SpanManager] Initialization failed:', error); |
| }); |
| } |
| } |
|
|
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', initializeSpanManager); |
| } else { |
| |
| initializeSpanManager(); |
| } |
|
|
| |
| |
| |
| setTimeout(() => { |
| if (window.spanManager && !window.spanManager.isInitialized) { |
| console.debug('[SpanManager] Retry initialization after 1s timeout'); |
| initializeSpanManager(); |
| } |
| }, 1000); |
|
|
| |
| if (typeof module !== 'undefined' && module.exports) { |
| module.exports = { SpanManager, UnifiedPositioningStrategy, getFontMetrics }; |
| } |
|
|