/** * Span Link Manager * * Handles the creation, management, and visualization of links/relationships * between spans in the Potato annotation platform. */ class SpanLinkManager { constructor(schemaName, spanSchemaName) { this.schemaName = schemaName; this.spanSchemaName = spanSchemaName; this.selectedSpans = []; this.currentLinkType = null; this.links = []; this.isLinkMode = false; this.linkTypeConfig = {}; // DOM element references this.container = document.getElementById(schemaName); this.selectedSpansDisplay = document.getElementById(`${schemaName}_selected_spans`); this.linkList = document.getElementById(`${schemaName}_link_list`); this.createButton = document.getElementById(`${schemaName}_create_link`); this.clearButton = document.getElementById(`${schemaName}_clear_selection`); this.showArcsCheckbox = document.getElementById(`${schemaName}_show_arcs`); this.linkDataInput = document.getElementById(`${schemaName}_link_data`); // Arc rendering this.arcsContainer = null; this.init(); } init() { console.log('[SpanLinkManager] init() called for schema:', this.schemaName); if (!this.container) { console.warn(`SpanLinkManager: Container not found for schema ${this.schemaName}`); return; } // Parse link type configurations this.parseLinkTypeConfigs(); console.log('[SpanLinkManager] Link type configs:', this.linkTypeConfig); // Set up event listeners this.setupEventListeners(); // Create arc rendering container this.createArcsContainer(); // Set up observer to re-render arcs when spans are added/removed this.setupSpanObserver(); // Load existing links if any this.loadExistingLinks(); console.log(`[SpanLinkManager] Initialization complete for schema: ${this.schemaName}`); console.log('[SpanLinkManager] Post-init state:', { hasArcsContainer: !!this.arcsContainer, hasArcSpacer: !!this.arcSpacer, hasTextWrapper: !!this.textWrapper }); } /** * Set up MutationObserver to watch for span overlay changes. * Re-renders arcs when spans are added, removed, or modified. */ setupSpanObserver() { const targetNode = this.textWrapper || document.getElementById('instance-text'); if (!targetNode) return; // Debounce re-renders to avoid excessive updates let renderTimeout = null; const debouncedRender = () => { if (renderTimeout) clearTimeout(renderTimeout); renderTimeout = setTimeout(() => { if (this.links.length > 0) { console.log('[SpanLinkManager] Span change detected, re-rendering arcs'); this.renderArcs(); } }, 100); }; this.spanObserver = new MutationObserver((mutations) => { // Check if any mutation involves span overlays const hasSpanChanges = mutations.some(mutation => { // Check added nodes for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList?.contains('span-overlay-pure') || node.classList?.contains('span-overlay') || node.querySelector?.('.span-overlay-pure, .span-overlay')) { return true; } } } // Check removed nodes for (const node of mutation.removedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList?.contains('span-overlay-pure') || node.classList?.contains('span-overlay')) { return true; } } } return false; }); if (hasSpanChanges) { debouncedRender(); } }); this.spanObserver.observe(targetNode, { childList: true, subtree: true }); } parseLinkTypeConfigs() { const linkTypes = this.container.querySelectorAll('.span-link-type'); linkTypes.forEach(lt => { const name = lt.dataset.linkType; this.linkTypeConfig[name] = { directed: lt.dataset.directed === 'true', maxSpans: parseInt(lt.dataset.maxSpans) || 2, color: lt.dataset.color || '#dc2626', sourceLabels: lt.dataset.sourceLabels ? lt.dataset.sourceLabels.split(',').filter(Boolean) : [], targetLabels: lt.dataset.targetLabels ? lt.dataset.targetLabels.split(',').filter(Boolean) : [] }; }); } setupEventListeners() { // Link type selection const linkTypeRadios = this.container.querySelectorAll('.span-link-type-radio'); linkTypeRadios.forEach(radio => { radio.addEventListener('change', (e) => { this.currentLinkType = e.target.value; this.enterLinkMode(); this.updateUI(); }); }); // Create button if (this.createButton) { this.createButton.addEventListener('click', () => this.createLink()); } // Clear button - exits link mode entirely so user can create new spans if (this.clearButton) { this.clearButton.addEventListener('click', () => this.exitLinkMode()); } // Escape key exits link mode document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isLinkMode) { e.preventDefault(); this.exitLinkMode(); } }); // Show arcs toggle if (this.showArcsCheckbox) { this.showArcsCheckbox.addEventListener('change', (e) => { this.toggleArcsVisibility(e.target.checked); }); } // Listen for span clicks when in link mode - use capture phase document.addEventListener('click', (e) => { if (!this.isLinkMode) return; console.log('[SpanLinkManager] Click detected in link mode, target:', e.target.className); // Check if clicked on a span overlay or any element inside it // This handles clicks on .span-highlight-segment, .span-label, .span-controls, etc. let spanOverlay = e.target.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); // Also check if we clicked on a segment inside an overlay if (!spanOverlay) { const segment = e.target.closest('.span-highlight-segment'); if (segment) { spanOverlay = segment.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai'); } } if (spanOverlay && spanOverlay.dataset.annotationId) { console.log('[SpanLinkManager] Found span overlay:', spanOverlay.className, 'annotationId:', spanOverlay.dataset.annotationId, 'label:', spanOverlay.dataset.label, 'start:', spanOverlay.dataset.start, 'end:', spanOverlay.dataset.end); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.handleSpanClick(spanOverlay); } else { console.log('[SpanLinkManager] No valid span overlay found at click target'); } }, true); // Also listen on mousedown to prevent text selection while in link mode document.addEventListener('mousedown', (e) => { if (!this.isLinkMode) return; const spanOverlay = e.target.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight, .span-highlight-segment'); if (spanOverlay) { e.preventDefault(); } }, true); } createArcsContainer() { console.log('[SpanLinkManager] createArcsContainer() called'); // Check if arc visualization is enabled const showArcs = this.container.dataset.showArcs !== 'false'; console.log('[SpanLinkManager] showArcs:', showArcs); if (!showArcs) { console.log('[SpanLinkManager] Arc visualization disabled'); return; } // Find the instance text container to place arcs relative to it const instanceText = document.getElementById('instance-text'); console.log('[SpanLinkManager] instanceText element:', instanceText); if (!instanceText) { console.error('[SpanLinkManager] No instance-text element found!'); return; } // Check if wrapper structure already exists (e.g., from previous initialization) const existingWrapper = instanceText.querySelector('.span-link-text-wrapper'); if (existingWrapper) { console.log('[SpanLinkManager] Wrapper structure already exists, reusing'); this.textWrapper = existingWrapper; this.arcSpacer = instanceText.querySelector('.span-link-arc-spacer'); this.arcsContainer = instanceText.querySelector('.span-link-arcs-container'); return; } // Store configuration for later use this.arcPosition = this.container.dataset.arcPosition || 'above'; this.multiLineMode = this.container.dataset.multiLineMode || 'bracket'; this.instanceText = instanceText; if (this.arcPosition === 'above') { if (this.multiLineMode === 'single_line') { // Single-line mode: display text on one line with horizontal scroll instanceText.style.whiteSpace = 'nowrap'; instanceText.style.overflowX = 'auto'; instanceText.classList.add('dependency-single-line-mode'); } else { // Bracket mode: wrapped text with bracket-style arcs for multi-line instanceText.classList.add('dependency-bracket-mode'); } } // Create a wrapper structure for reliable arc positioning: // 1. Arc spacer div (takes up vertical space for arcs) // 2. Text wrapper (contains the actual text content) // 3. Arc SVG overlay (positioned absolutely over the spacer) // Wrap existing content in a text wrapper this.textWrapper = document.createElement('div'); this.textWrapper.className = 'span-link-text-wrapper'; this.textWrapper.style.cssText = 'position: relative;'; // Move all existing children into the wrapper while (instanceText.firstChild) { this.textWrapper.appendChild(instanceText.firstChild); } // Create spacer div for arc area (will be sized dynamically) this.arcSpacer = document.createElement('div'); this.arcSpacer.className = 'span-link-arc-spacer'; this.arcSpacer.style.cssText = ` position: relative; width: 100%; height: 100px; min-height: 100px; `; // Create SVG container for arcs - inside the spacer this.arcsContainer = document.createElement('div'); this.arcsContainer.id = `${this.schemaName}_arcs`; this.arcsContainer.className = 'span-link-arcs-container'; this.arcsContainer.style.cssText = ` position: absolute; bottom: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; z-index: 100; `; // Assemble the structure this.arcSpacer.appendChild(this.arcsContainer); instanceText.appendChild(this.arcSpacer); instanceText.appendChild(this.textWrapper); console.log('[SpanLinkManager] Created arc container structure with spacer'); } /** * Dynamically update the arc spacer height based on required arc height */ updateArcSpacerHeight(requiredHeight) { if (!this.arcSpacer) return; // Add margin for labels (25px) and buffer (15px) const totalHeight = requiredHeight + 40; console.log(`[SpanLinkManager] Setting arc spacer height: ${totalHeight}px (arc height: ${requiredHeight}px)`); this.arcSpacer.style.height = `${totalHeight}px`; this.arcSpacer.style.minHeight = `${totalHeight}px`; } enterLinkMode() { this.isLinkMode = true; this.clearSelection(); // Get the color for the current link type const linkColor = this.linkTypeConfig[this.currentLinkType]?.color || '#6E56CF'; // Add visual indicator that link mode is active this.container.classList.add('link-mode-active'); // Add body class to indicate link mode is active (used to disable span annotation) document.body.classList.add('span-link-mode-active'); // Set the link color as a CSS variable on the body for use in selection styles document.body.style.setProperty('--current-link-color', linkColor); // Enable pointer events and highlight clickable spans const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); console.log(`[SpanLinkManager] Found ${spanOverlays.length} span overlays to make selectable`); spanOverlays.forEach(overlay => { overlay.classList.add('link-selectable'); // Set the link color on each overlay for CSS to use overlay.style.setProperty('--current-link-color', linkColor); // Enable pointer events so we can click on them overlay.style.pointerEvents = 'auto'; overlay.style.cursor = 'pointer'; }); // Also enable pointer events on highlight segments inside overlays const segments = document.querySelectorAll('.span-highlight-segment'); segments.forEach(segment => { segment.style.pointerEvents = 'auto'; segment.style.cursor = 'pointer'; }); console.log(`[SpanLinkManager] Entered link mode with type: ${this.currentLinkType}`); } exitLinkMode() { this.isLinkMode = false; this.container.classList.remove('link-mode-active'); // Remove body class and CSS variable document.body.classList.remove('span-link-mode-active'); document.body.style.removeProperty('--current-link-color'); // Remove visual indicators and reset pointer events const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); spanOverlays.forEach(overlay => { overlay.classList.remove('link-selectable'); overlay.classList.remove('link-selected'); // Reset pointer events to original state overlay.style.pointerEvents = 'none'; overlay.style.cursor = ''; // Remove color variable overlay.style.removeProperty('--current-link-color'); }); // Reset pointer events on segments const segments = document.querySelectorAll('.span-highlight-segment'); segments.forEach(segment => { segment.style.pointerEvents = 'none'; segment.style.cursor = ''; }); // Deselect radio const linkTypeRadios = this.container.querySelectorAll('.span-link-type-radio'); linkTypeRadios.forEach(radio => radio.checked = false); this.currentLinkType = null; this.clearSelection(); } /** * Extract the actual text content for a span using its start/end offsets */ getSpanText(spanOverlay) { const start = parseInt(spanOverlay.dataset.start); const end = parseInt(spanOverlay.dataset.end); if (isNaN(start) || isNaN(end)) { console.warn('Span overlay missing start/end offsets'); return '(unknown)'; } // Get the original text - span-core.js stores it in #text-content's data-original-text const textContent = document.getElementById('text-content'); if (textContent && textContent.hasAttribute('data-original-text')) { const originalText = textContent.getAttribute('data-original-text'); // Strip HTML tags and normalize whitespace like span-core does const cleanText = originalText.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); console.log(`[SpanLinkManager] getSpanText: start=${start}, end=${end}, text="${cleanText.substring(start, end)}"`); return cleanText.substring(start, end); } // Fallback: try to get from spanManager if available if (window.spanManager && typeof window.spanManager.getCanonicalText === 'function') { const canonicalText = window.spanManager.getCanonicalText(); console.log(`[SpanLinkManager] getSpanText from spanManager: start=${start}, end=${end}, text="${canonicalText.substring(start, end)}"`); return canonicalText.substring(start, end); } // Last resort fallback const instanceText = document.getElementById('instance-text'); if (instanceText) { const rawText = instanceText.textContent.replace(/\s+/g, ' ').trim(); console.log(`[SpanLinkManager] getSpanText fallback: start=${start}, end=${end}, text="${rawText.substring(start, end)}"`); return rawText.substring(start, end); } return '(unknown)'; } handleSpanClick(spanOverlay) { const spanId = spanOverlay.dataset.spanId || spanOverlay.dataset.annotationId; const spanLabel = spanOverlay.dataset.label; if (!spanId) { console.warn('Span overlay has no span ID'); return; } // Get the actual span text const spanText = this.getSpanText(spanOverlay); console.log(`[SpanLinkManager] Span clicked: ${spanLabel} = "${spanText}"`); // Validate span label constraints if (this.currentLinkType && this.linkTypeConfig[this.currentLinkType]) { const config = this.linkTypeConfig[this.currentLinkType]; const isFirst = this.selectedSpans.length === 0; // Check source label constraint for first span (directed links) if (isFirst && config.directed && config.sourceLabels.length > 0) { if (!config.sourceLabels.includes(spanLabel)) { console.log(`Span label ${spanLabel} not allowed as source`); this.showConstraintError(`Source must be: ${config.sourceLabels.join(', ')}`); return; } } // Check target label constraint for subsequent spans (directed links) if (!isFirst && config.directed && config.targetLabels.length > 0) { if (!config.targetLabels.includes(spanLabel)) { console.log(`Span label ${spanLabel} not allowed as target`); this.showConstraintError(`Target must be: ${config.targetLabels.join(', ')}`); return; } } } // Toggle selection const existingIndex = this.selectedSpans.findIndex(s => s.id === spanId); if (existingIndex >= 0) { // Deselect this.selectedSpans.splice(existingIndex, 1); spanOverlay.classList.remove('link-selected'); } else { // Check max spans limit const maxSpans = this.linkTypeConfig[this.currentLinkType]?.maxSpans || 2; if (this.selectedSpans.length >= maxSpans) { this.showConstraintError(`Maximum ${maxSpans} spans for this link type`); return; } // Select - use extracted text, not textContent this.selectedSpans.push({ id: spanId, label: spanLabel, text: spanText, element: spanOverlay }); spanOverlay.classList.add('link-selected'); } this.updateUI(); } showConstraintError(message) { // Show a temporary error message const existingError = this.container.querySelector('.constraint-error'); if (existingError) existingError.remove(); const errorDiv = document.createElement('div'); errorDiv.className = 'constraint-error'; errorDiv.textContent = message; this.container.querySelector('.span-link-selection').appendChild(errorDiv); setTimeout(() => errorDiv.remove(), 3000); } selectSpanForLink(spanId) { const spanOverlay = document.querySelector( `.span-overlay-pure[data-annotation-id="${spanId}"], ` + `.span-overlay[data-annotation-id="${spanId}"], ` + `.span-highlight[data-annotation-id="${spanId}"]` ); if (spanOverlay) { this.handleSpanClick(spanOverlay); } } clearSelection() { this.selectedSpans.forEach(span => { if (span.element) { span.element.classList.remove('link-selected'); } }); this.selectedSpans = []; this.updateUI(); } async createLink() { if (!this.currentLinkType || this.selectedSpans.length < 2) { console.warn('Cannot create link: need link type and at least 2 spans'); return; } const config = this.linkTypeConfig[this.currentLinkType] || {}; // Extract span positions for repair matching on reload const spanPositions = this.selectedSpans.map(s => { if (s.element) { return { start: parseInt(s.element.dataset.start), end: parseInt(s.element.dataset.end) }; } return null; }); const link = { id: `link_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, schema: this.schemaName, link_type: this.currentLinkType, span_ids: this.selectedSpans.map(s => s.id), direction: config.directed ? 'directed' : 'undirected', properties: { color: config.color, span_labels: this.selectedSpans.map(s => s.label), span_texts: this.selectedSpans.map(s => s.text.substring(0, 30)), span_positions: spanPositions // For fallback matching when span IDs change } }; // Add to local list this.links.push(link); // Save to backend await this.saveLink(link); // Update UI this.updateLinkList(); this.renderArcs(); this.clearSelection(); console.log('Created link:', link); } async saveLink(link) { const instanceId = document.getElementById('instance_id')?.value; if (!instanceId) { console.error('No instance ID found'); return; } try { const response = await fetch('/updateinstance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instance_id: instanceId, annotations: {}, // Required for frontend format detection link_annotations: [link] }) }); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } console.log('Link saved successfully'); } catch (error) { console.error('Error saving link:', error); } } async deleteLink(linkId) { const instanceId = document.getElementById('instance_id')?.value; if (!instanceId) return; // Remove from local list this.links = this.links.filter(l => l.id !== linkId); try { const response = await fetch(`/api/links/${instanceId}/${linkId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } console.log('Link deleted successfully'); } catch (error) { console.error('Error deleting link:', error); } // Update UI this.updateLinkList(); this.renderArcs(); } async loadExistingLinks() { const instanceId = document.getElementById('instance_id')?.value; console.log('[SpanLinkManager] loadExistingLinks called, instanceId:', instanceId); if (!instanceId) { console.warn('[SpanLinkManager] No instance ID, skipping load'); return; } try { console.log('[SpanLinkManager] Fetching links from API...'); const response = await fetch(`/api/links/${instanceId}`); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const data = await response.json(); this.links = data.links || []; // Update UI this.updateLinkList(); console.log(`[SpanLinkManager] Loaded ${this.links.length} existing links:`, this.links); // Render arcs after waiting for spans to be created // Span overlays are created asynchronously by span-manager.js if (this.links.length > 0) { console.log('[SpanLinkManager] Starting waitForSpansAndRender...'); this.waitForSpansAndRender(); } else { console.log('[SpanLinkManager] No links to render'); } } catch (error) { console.error('[SpanLinkManager] Error loading links:', error); } } /** * Try to repair orphaned link span_ids by matching with current spans. * This handles the case where spans were recreated with new UUIDs. * Uses span positions (start/end) and labels for precise matching. */ repairOrphanedLinks() { const currentSpanIds = new Set(Object.keys(this.getSpanPositions())); let repaired = false; this.links.forEach(link => { const orphanedIds = link.span_ids.filter(id => !currentSpanIds.has(id)); if (orphanedIds.length > 0) { console.log('[SpanLinkManager] Found orphaned span IDs in link:', orphanedIds); // Get all current spans from DOM const allOverlays = document.querySelectorAll( '.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight' ); // Build lookup by position (start, end, label) for precise matching const spansByPosition = new Map(); allOverlays.forEach(overlay => { const start = overlay.dataset.start; const end = overlay.dataset.end; const label = overlay.dataset.label; const id = overlay.dataset.annotationId || overlay.dataset.spanId; if (start && end && label && id) { const key = `${start}_${end}_${label}`; spansByPosition.set(key, id); } }); // Get stored span metadata const spanPositions = link.properties?.span_positions || []; const spanLabels = link.properties?.span_labels || []; const repairs = []; orphanedIds.forEach(orphanedId => { const orphanedIdx = link.span_ids.indexOf(orphanedId); const position = spanPositions[orphanedIdx]; const label = spanLabels[orphanedIdx]; // Try to match by position and label first (most precise) if (position && label) { const key = `${position.start}_${position.end}_${label}`; const newId = spansByPosition.get(key); if (newId && !link.span_ids.includes(newId)) { console.log(`[SpanLinkManager] Repairing by position: ${orphanedId} -> ${newId} (${key})`); repairs.push({ old: orphanedId, new: newId }); return; } } // Fallback: match by label only if position matching fails if (label) { for (const overlay of allOverlays) { const overlayId = overlay.dataset.annotationId || overlay.dataset.spanId; const overlayLabel = overlay.dataset.label; if (overlayLabel === label && !link.span_ids.includes(overlayId) && !repairs.some(r => r.new === overlayId)) { console.log(`[SpanLinkManager] Repairing by label fallback: ${orphanedId} -> ${overlayId} (label: ${label})`); repairs.push({ old: orphanedId, new: overlayId }); break; } } } }); // Apply repairs repairs.forEach(repair => { const idx = link.span_ids.indexOf(repair.old); if (idx !== -1) { link.span_ids[idx] = repair.new; repaired = true; } }); } }); if (repaired) { console.log('[SpanLinkManager] Links repaired, re-rendering'); } return repaired; } /** * Wait for span overlays to exist, then render arcs. * This handles the timing issue where span-manager.js creates spans asynchronously. */ waitForSpansAndRender() { console.log('[SpanLinkManager] waitForSpansAndRender() called'); if (this.links.length === 0) { console.log('[SpanLinkManager] No links to render'); return; } // Get the span IDs we need to render const neededSpanIds = new Set(); this.links.forEach(link => { link.span_ids.forEach(id => neededSpanIds.add(id)); }); console.log('[SpanLinkManager] Waiting for spans:', [...neededSpanIds]); // Check if spans exist const checkSpans = () => { const positions = this.getSpanPositions(); const foundIds = Object.keys(positions); const allFound = [...neededSpanIds].every(id => foundIds.includes(id)); console.log(`[SpanLinkManager] Span check: found ${foundIds.length} spans, need ${neededSpanIds.size}, allFound=${allFound}`); console.log('[SpanLinkManager] Found span IDs:', foundIds); console.log('[SpanLinkManager] Needed span IDs:', [...neededSpanIds]); if (allFound || foundIds.length > 0) { console.log('[SpanLinkManager] Spans found, rendering arcs'); // Try to repair orphaned links before rendering this.repairOrphanedLinks(); this.renderArcs(); return true; } return false; }; // Try immediately console.log('[SpanLinkManager] Attempting immediate span check...'); if (checkSpans()) { console.log('[SpanLinkManager] Immediate check succeeded'); return; } console.log('[SpanLinkManager] Immediate check failed, starting retry loop'); // Retry with delays (spans might be loading async) const delays = [100, 250, 500, 1000, 2000]; let attempt = 0; const retry = () => { if (attempt >= delays.length) { console.warn('[SpanLinkManager] Gave up waiting for spans after all retries'); console.log('[SpanLinkManager] Final span overlay count:', document.querySelectorAll('.span-overlay-pure, .span-overlay').length); // Render anyway to show the link list even if arcs can't be drawn this.renderArcs(); return; } setTimeout(() => { console.log(`[SpanLinkManager] Retry attempt ${attempt + 1}/${delays.length} after ${delays[attempt]}ms`); if (!checkSpans()) { attempt++; retry(); } else { console.log('[SpanLinkManager] Retry succeeded on attempt', attempt + 1); } }, delays[attempt]); }; retry(); } /** * Escape HTML to prevent XSS */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Truncate text with ellipsis */ truncateText(text, maxLength = 25) { if (!text) return ''; const escaped = this.escapeHtml(text); if (text.length <= maxLength) return escaped; return this.escapeHtml(text.substring(0, maxLength)) + '...'; } updateUI() { // Update selected spans display if (this.selectedSpansDisplay) { if (this.selectedSpans.length === 0) { const instruction = this.isLinkMode ? '
Click on highlighted spans to select them for linking. Press Esc or click "Exit Link Mode" to create new spans.
' : ''; this.selectedSpansDisplay.innerHTML = instruction; } else { const spansHtml = this.selectedSpans.map((span, index) => { const config = this.linkTypeConfig[this.currentLinkType] || {}; let roleLabel = ''; if (config.directed) { roleLabel = index === 0 ? '(source)' : '(target)'; } const displayText = this.truncateText(span.text, 25); return `