Spaces:
Paused
Paused
| // annotation.js | |
| // Debug logging utility - respects the debug setting from server config | |
| function debugLog(...args) { | |
| if (window.config && window.config.debug) { | |
| console.log(...args); | |
| } | |
| } | |
| /** | |
| * Compatibility stub for registerAnnotation from base_template.html | |
| * This function is called by onclick handlers in checkbox/radio schemas. | |
| * In the v1 template, this sent annotations directly to the server. | |
| * In the v2 template (annotation.js), we use change event listeners instead. | |
| * This stub prevents JavaScript errors while the change event handler does the work. | |
| */ | |
| function registerAnnotation(element) { | |
| debugLog('[COMPAT] registerAnnotation called for:', element?.id); | |
| // The actual annotation saving is handled by the change event listener | |
| // in setupInputEventListeners(). This stub just prevents the ReferenceError. | |
| } | |
| /** | |
| * Compatibility stub for registerTextAnnotation from base_template.html | |
| */ | |
| function registerTextAnnotation(element) { | |
| debugLog('[COMPAT] registerTextAnnotation called for:', element?.id); | |
| // Actual saving is handled by the input event listener. | |
| } | |
| function debugWarn(...args) { | |
| if (window.config && window.config.debug) { | |
| console.warn(...args); | |
| } | |
| } | |
| // Global state | |
| let currentInstance = null; | |
| let currentAnnotations = {}; | |
| let userState = null; | |
| let isLoading = false; | |
| let textSaveTimer = null; | |
| let currentSpanAnnotations = []; | |
| let debugLastInstanceId = null; | |
| let debugOverlayCount = 0; | |
| // Validation state — errors only shown after first forward navigation attempt | |
| let hasAttemptedForwardValidation = false; | |
| // Stored event handler references for proper cleanup (prevents memory leaks) | |
| const boundEventHandlers = { | |
| spanManagerMouseUp: null, | |
| spanManagerKeyUp: null, | |
| robustTextSelectionMouseUp: null, | |
| robustTextSelectionKeyUp: null | |
| }; | |
| let aiAssistantManger = new AIAssistantManager(); | |
| /** | |
| * Flush any pending debounced save synchronously using navigator.sendBeacon(). | |
| * Called from beforeunload and visibilitychange so annotations are not lost | |
| * when the user refreshes or switches tabs before the 500ms debounce fires. | |
| * sendBeacon is the W3C standard for fire-and-forget requests during page unload — | |
| * regular fetch() is cancelled by browsers during unload. | |
| */ | |
| function flushPendingSave() { | |
| if (!textSaveTimer || !currentInstance) return; | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = null; | |
| syncAnnotationsFromDOM(); | |
| const labelAnnotations = {}; | |
| for (const [schema, labels] of Object.entries(currentAnnotations)) { | |
| for (const [label, value] of Object.entries(labels)) { | |
| labelAnnotations[`${schema}:${label}`] = value; | |
| } | |
| } | |
| const payload = JSON.stringify({ | |
| instance_id: currentInstance.id, | |
| annotations: labelAnnotations, | |
| span_annotations: extractSpanAnnotationsFromDOM() | |
| }); | |
| navigator.sendBeacon('/updateinstance', | |
| new Blob([payload], {type: 'application/json'})); | |
| } | |
| window.addEventListener('beforeunload', flushPendingSave); | |
| document.addEventListener('visibilitychange', function() { | |
| if (document.visibilityState === 'hidden') flushPendingSave(); | |
| }); | |
| // DEEP DEBUG: Enhanced tracking | |
| let deepDebugState = { | |
| navigationCalls: 0, | |
| instanceIdChanges: [], | |
| overlayStates: [], | |
| spanManagerCalls: [], | |
| lastAction: null, | |
| timestamp: new Date().toISOString() | |
| }; | |
| /** | |
| * FormLayoutManager - Manages annotation form grid layout | |
| * | |
| * Handles: | |
| * - CSS grid configuration from layout config | |
| * - Form grouping with collapsible sections | |
| * - Explicit ordering of forms | |
| * - Responsive breakpoint customization | |
| */ | |
| class FormLayoutManager { | |
| constructor() { | |
| this.config = null; | |
| this.initialized = false; | |
| } | |
| /** | |
| * Initialize the layout manager with configuration | |
| * @param {Object} layoutConfig - Layout configuration from server | |
| */ | |
| initialize(layoutConfig = {}) { | |
| this.config = this.mergeDefaults(layoutConfig); | |
| this.applyGridProperties(); | |
| this.wrapFormsInLayoutContainer(); | |
| this.setupGroups(); | |
| this.applyOrdering(); | |
| this.setupResponsiveBreakpoints(); | |
| this.initialized = true; | |
| debugLog('[FormLayoutManager] Initialized with config:', this.config); | |
| } | |
| /** | |
| * Merge user config with sensible defaults | |
| */ | |
| mergeDefaults(config) { | |
| return { | |
| grid: { | |
| columns: 2, | |
| gap: '1rem', | |
| row_gap: null, | |
| align_items: 'start', | |
| ...config?.grid | |
| }, | |
| breakpoints: { | |
| mobile: 480, | |
| tablet: 768, | |
| ...config?.breakpoints | |
| }, | |
| styling: { | |
| align_items: 'start', | |
| content_align: 'left', | |
| group_background_odd: '#fafafa', | |
| group_background_even: '#f8f9fc', | |
| group_padding: '0.5rem 0.75rem', | |
| form_padding: '0.375rem 0.5rem', | |
| ...config?.styling | |
| }, | |
| groups: config?.groups || [], | |
| order: config?.order || null | |
| }; | |
| } | |
| /** | |
| * Apply grid and styling CSS custom properties to document root | |
| */ | |
| applyGridProperties() { | |
| const root = document.documentElement; | |
| // Grid properties | |
| root.style.setProperty('--layout-columns', this.config.grid.columns); | |
| root.style.setProperty('--layout-gap', this.config.grid.gap); | |
| root.style.setProperty('--layout-row-gap', this.config.grid.row_gap || this.config.grid.gap); | |
| // Alignment (use styling.align_items if present, fallback to grid.align_items) | |
| const alignItems = this.config.styling.align_items || this.config.grid.align_items || 'start'; | |
| root.style.setProperty('--layout-align', alignItems); | |
| // Content alignment | |
| root.style.setProperty('--layout-content-align', this.config.styling.content_align); | |
| // Group background colors | |
| root.style.setProperty('--group-bg-odd', this.config.styling.group_background_odd); | |
| root.style.setProperty('--group-bg-even', this.config.styling.group_background_even); | |
| // Padding | |
| root.style.setProperty('--group-padding', this.config.styling.group_padding); | |
| root.style.setProperty('--form-padding', this.config.styling.form_padding); | |
| } | |
| /** | |
| * Wrap annotation forms in a layout container | |
| */ | |
| wrapFormsInLayoutContainer() { | |
| const container = document.getElementById('annotation-forms'); | |
| if (!container) return; | |
| // Check if already wrapped | |
| if (container.querySelector('.annotation-forms-layout')) { | |
| debugLog('[FormLayoutManager] Layout container already exists'); | |
| return; | |
| } | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'annotation-forms-layout'; | |
| // Get all annotation forms | |
| const forms = container.querySelectorAll('.annotation-form'); | |
| if (forms.length === 0) { | |
| debugLog('[FormLayoutManager] No annotation forms found'); | |
| return; | |
| } | |
| // Move forms into wrapper | |
| forms.forEach(form => { | |
| // Set default data-grid-columns if not present | |
| if (!form.hasAttribute('data-grid-columns')) { | |
| form.setAttribute('data-grid-columns', '1'); | |
| } | |
| wrapper.appendChild(form); | |
| }); | |
| // Insert wrapper at the beginning of the container (after any pairwise display) | |
| const pairwiseDisplay = container.querySelector('.pairwise-items-display-container'); | |
| if (pairwiseDisplay) { | |
| pairwiseDisplay.after(wrapper); | |
| } else { | |
| container.insertBefore(wrapper, container.firstChild); | |
| } | |
| debugLog('[FormLayoutManager] Wrapped', forms.length, 'forms in layout container'); | |
| } | |
| /** | |
| * Setup form groups with headers and collapsible behavior | |
| */ | |
| setupGroups() { | |
| if (!this.config.groups || this.config.groups.length === 0) return; | |
| const container = document.querySelector('.annotation-forms-layout') || | |
| document.querySelector('.annotation-forms-grid'); | |
| if (!container) return; | |
| this.config.groups.forEach(groupConfig => { | |
| const groupElement = this.createGroupElement(groupConfig, container); | |
| if (groupElement) { | |
| // Move specified schemas into the group | |
| groupConfig.schemas.forEach(schemaName => { | |
| const form = container.querySelector(`[data-schema-name="${schemaName}"]`); | |
| if (form) { | |
| const content = groupElement.querySelector('.annotation-form-group-content'); | |
| if (content) { | |
| content.appendChild(form); | |
| } | |
| } | |
| }); | |
| // Insert the group into the container | |
| container.appendChild(groupElement); | |
| } | |
| }); | |
| debugLog('[FormLayoutManager] Setup', this.config.groups.length, 'groups'); | |
| } | |
| /** | |
| * Create a group element with header and content container | |
| */ | |
| createGroupElement(groupConfig, container) { | |
| const group = document.createElement('div'); | |
| group.className = 'annotation-form-group'; | |
| group.id = `group-${groupConfig.id}`; | |
| // Apply per-group custom background color if specified | |
| if (groupConfig.background_color) { | |
| group.style.setProperty('--group-bg', groupConfig.background_color); | |
| group.style.backgroundColor = groupConfig.background_color; | |
| } | |
| if (groupConfig.collapsed_default) { | |
| group.classList.add('collapsed'); | |
| } | |
| let headerHtml = ` | |
| <div class="annotation-form-group-header"> | |
| <div> | |
| ${groupConfig.title ? `<h4 class="annotation-form-group-title">${this.escapeHtml(groupConfig.title)}</h4>` : ''} | |
| ${groupConfig.description ? `<p class="annotation-form-group-description">${this.escapeHtml(groupConfig.description)}</p>` : ''} | |
| </div> | |
| `; | |
| if (groupConfig.collapsible) { | |
| headerHtml += ` | |
| <button type="button" class="annotation-form-group-toggle" aria-label="Toggle group"> | |
| <i class="fas fa-chevron-down"></i> | |
| </button> | |
| `; | |
| } | |
| headerHtml += '</div>'; | |
| group.innerHTML = headerHtml + '<div class="annotation-form-group-content"></div>'; | |
| // Setup toggle behavior | |
| if (groupConfig.collapsible) { | |
| const toggle = group.querySelector('.annotation-form-group-toggle'); | |
| toggle.addEventListener('click', () => { | |
| group.classList.toggle('collapsed'); | |
| }); | |
| } | |
| return group; | |
| } | |
| /** | |
| * Apply explicit ordering to forms | |
| */ | |
| applyOrdering() { | |
| const container = document.querySelector('.annotation-forms-layout') || | |
| document.querySelector('.annotation-forms-grid'); | |
| if (!container) return; | |
| // Apply order from config.order array | |
| if (this.config.order && Array.isArray(this.config.order)) { | |
| this.config.order.forEach((schemaName, index) => { | |
| const form = container.querySelector(`[data-schema-name="${schemaName}"]`); | |
| if (form) { | |
| form.style.order = index; | |
| } | |
| }); | |
| } | |
| // Also apply order from data-grid-order attributes | |
| const formsWithOrder = container.querySelectorAll('[data-grid-order]'); | |
| formsWithOrder.forEach(form => { | |
| const order = parseInt(form.getAttribute('data-grid-order'), 10); | |
| if (!isNaN(order)) { | |
| form.style.order = order; | |
| } | |
| }); | |
| } | |
| /** | |
| * Setup custom responsive breakpoints via media query injection | |
| */ | |
| setupResponsiveBreakpoints() { | |
| const mobile = this.config.breakpoints.mobile; | |
| const tablet = this.config.breakpoints.tablet; | |
| // Only inject custom breakpoints if they differ from defaults | |
| if (mobile !== 480 || tablet !== 768) { | |
| const styleId = 'layout-breakpoints-custom'; | |
| let styleEl = document.getElementById(styleId); | |
| if (!styleEl) { | |
| styleEl = document.createElement('style'); | |
| styleEl.id = styleId; | |
| document.head.appendChild(styleEl); | |
| } | |
| styleEl.textContent = ` | |
| @media (max-width: ${mobile}px) { | |
| .annotation-forms-layout, | |
| .annotation-forms-grid { | |
| --layout-columns: 1 !important; | |
| } | |
| .annotation-forms-layout .annotation-form[data-grid-columns], | |
| .annotation-forms-grid .annotation-form[data-grid-columns] { | |
| grid-column: span 1 !important; | |
| } | |
| } | |
| @media (min-width: ${mobile + 1}px) and (max-width: ${tablet}px) { | |
| .annotation-forms-layout .annotation-form[data-grid-columns="3"], | |
| .annotation-forms-layout .annotation-form[data-grid-columns="4"], | |
| .annotation-forms-layout .annotation-form[data-grid-columns="5"], | |
| .annotation-forms-layout .annotation-form[data-grid-columns="6"], | |
| .annotation-forms-grid .annotation-form[data-grid-columns="3"], | |
| .annotation-forms-grid .annotation-form[data-grid-columns="4"], | |
| .annotation-forms-grid .annotation-form[data-grid-columns="5"], | |
| .annotation-forms-grid .annotation-form[data-grid-columns="6"] { | |
| grid-column: span 2; | |
| } | |
| } | |
| `; | |
| } | |
| } | |
| /** | |
| * Helper to escape HTML | |
| */ | |
| escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| } | |
| // Global FormLayoutManager instance | |
| window.formLayoutManager = new FormLayoutManager(); | |
| /** | |
| * Deep debug logging for navigation events - only logs when debug mode is enabled | |
| */ | |
| function logDeepDebug(action, extraData = {}) { | |
| // Skip all debug logging when not in debug mode | |
| if (!window.config || !window.config.debug) { | |
| return; | |
| } | |
| const state = { | |
| timestamp: new Date().toISOString(), | |
| action: action, | |
| currentInstanceId: currentInstance?.id, | |
| debugLastInstanceId: debugLastInstanceId, | |
| isLoading: isLoading, | |
| overlayCount: getCurrentOverlayCount(), | |
| spanManagerExists: !!window.spanManager, | |
| spanManagerInitialized: window.spanManager?.isInitialized, | |
| ...extraData | |
| }; | |
| debugLog(`[DEEP DEBUG NAV] ${action}:`, state); | |
| deepDebugState.lastAction = action; | |
| deepDebugState.timestamp = new Date().toISOString(); | |
| // Track instance ID changes | |
| if (extraData.newInstanceId || extraData.currentInstanceId) { | |
| deepDebugState.instanceIdChanges.push({ | |
| timestamp: new Date().toISOString(), | |
| from: debugLastInstanceId, | |
| to: extraData.newInstanceId || extraData.currentInstanceId, | |
| action: action | |
| }); | |
| } | |
| // Track overlay states | |
| deepDebugState.overlayStates.push({ | |
| timestamp: new Date().toISOString(), | |
| action: action, | |
| overlayCount: getCurrentOverlayCount(), | |
| instanceId: currentInstance?.id | |
| }); | |
| // Keep only last 20 entries to avoid memory bloat | |
| if (deepDebugState.instanceIdChanges.length > 20) { | |
| deepDebugState.instanceIdChanges = deepDebugState.instanceIdChanges.slice(-20); | |
| } | |
| if (deepDebugState.overlayStates.length > 20) { | |
| deepDebugState.overlayStates = deepDebugState.overlayStates.slice(-20); | |
| } | |
| } | |
| /** | |
| * Get current overlay count for debugging | |
| */ | |
| function getCurrentOverlayCount() { | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| return spanOverlays ? spanOverlays.children.length : 0; | |
| } | |
| // Initialize the application | |
| document.addEventListener('DOMContentLoaded', function () { | |
| // Skip annotation initialization on non-annotation pages (consent, instructions, etc.) | |
| // but still show the page content, enable navigation, and wire up input persistence | |
| if (window.config && !window.config.is_annotation_page) { | |
| // Create a synthetic instance so saveAnnotations() can send updates. | |
| // The backend routes non-annotation saves to phase_to_page_to_label_to_value. | |
| currentInstance = { id: '__phase_page__', text: '', displayed_text: '' }; | |
| window.currentInstance = currentInstance; | |
| currentAnnotations = {}; | |
| setLoading(false); | |
| setupInputEventListeners(); | |
| validateRequiredFields(); | |
| return; | |
| } | |
| loadCurrentInstance(); | |
| setupEventListeners(); | |
| // Initial validation check | |
| validateRequiredFields(); | |
| // Initialize span manager integration | |
| initializeSpanManagerIntegration(); | |
| // Initialize display logic for conditional schemas | |
| if (typeof initDisplayLogic === 'function') { | |
| initDisplayLogic(); | |
| } | |
| // Initialize form layout manager (if layout config is available) | |
| // Layout config is passed via ui_config from the server | |
| const layoutConfig = window.config?.ui_config?.layout || window.config?.layout; | |
| if (layoutConfig) { | |
| window.formLayoutManager.initialize(layoutConfig); | |
| } | |
| // Initialize pairwise annotation | |
| initPairwiseAnnotation(); | |
| // Initialize BWS annotation | |
| initBwsAnnotation(); | |
| }); | |
| /** | |
| * Global overlay tracking for debugging - only logs when debug mode is enabled | |
| */ | |
| function trackOverlayCreation(overlay, context = 'unknown') { | |
| if (!window.config || !window.config.debug) return; | |
| debugLog(`[DEBUG] OVERLAY CREATED in ${context}:`, { | |
| className: overlay.className, | |
| id: overlay.id, | |
| parentId: overlay.parentElement?.id, | |
| timestamp: new Date().toISOString() | |
| }); | |
| // Track total overlays | |
| const totalOverlays = document.querySelectorAll('.span-overlay').length; | |
| debugLog(`[DEBUG] TOTAL OVERLAYS after creation: ${totalOverlays}`); | |
| } | |
| function trackOverlayRemoval(overlay, context = 'unknown') { | |
| if (!window.config || !window.config.debug) return; | |
| debugLog(`[DEBUG] OVERLAY REMOVED in ${context}:`, { | |
| className: overlay.className, | |
| id: overlay.id, | |
| timestamp: new Date().toISOString() | |
| }); | |
| // Track total overlays | |
| const totalOverlays = document.querySelectorAll('.span-overlay').length; | |
| debugLog(`[DEBUG] TOTAL OVERLAYS after removal: ${totalOverlays}`); | |
| } | |
| function debugTrackOverlays(action, instanceId = null) { | |
| if (!window.config || !window.config.debug) return; | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| const overlayCount = spanOverlays ? spanOverlays.children.length : 0; | |
| const instanceText = document.getElementById('instance-text'); | |
| const textContent = document.getElementById('text-content'); | |
| debugLog(`[DEBUG OVERLAY TRACKING] ${action}:`, { | |
| instanceId: instanceId || currentInstance?.id, | |
| lastInstanceId: debugLastInstanceId, | |
| overlayCount: overlayCount, | |
| spanOverlaysExists: !!spanOverlays, | |
| instanceTextExists: !!instanceText, | |
| textContentExists: !!textContent, | |
| spanOverlaysHTML: spanOverlays ? spanOverlays.innerHTML.substring(0, 200) + '...' : 'null', | |
| timestamp: new Date().toISOString() | |
| }); | |
| debugOverlayCount = overlayCount; | |
| if (instanceId) debugLastInstanceId = instanceId; | |
| } | |
| // DEBUG: Add overlay cleanup verification - only logs when debug mode is enabled | |
| function debugVerifyOverlayCleanup() { | |
| if (!window.config || !window.config.debug) return; | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| if (!spanOverlays) { | |
| debugWarn('[DEBUG] span-overlays container not found during cleanup verification'); | |
| return; | |
| } | |
| const overlayCount = spanOverlays.children.length; | |
| debugLog(`[DEBUG] Overlay cleanup verification:`, { | |
| overlayCount: overlayCount, | |
| containerEmpty: overlayCount === 0, | |
| containerInnerHTML: spanOverlays.innerHTML, | |
| containerChildren: Array.from(spanOverlays.children).map(child => ({ | |
| tagName: child.tagName, | |
| className: child.className, | |
| dataset: child.dataset | |
| })) | |
| }); | |
| if (overlayCount > 0) { | |
| debugWarn('[DEBUG] WARNING: Overlays still present after expected cleanup!'); | |
| } | |
| } | |
| function setupEventListeners() { | |
| // Prevent default form submission on all annotation forms (defense in depth) | |
| document.querySelectorAll('.annotation-form').forEach(function(form) { | |
| form.addEventListener('submit', function(e) { e.preventDefault(); }); | |
| }); | |
| // Go to button (may not exist when jumping_to_id_disabled is true) | |
| const goToBtn = document.getElementById('go-to-btn'); | |
| const goToInput = document.getElementById('go_to'); | |
| if (goToBtn && goToInput) { | |
| goToBtn.addEventListener('click', function () { | |
| const goToValue = goToInput.value; | |
| if (goToValue && goToValue > 0) { | |
| // User enters 1-based index (item 1, 2, 3...) but server uses 0-based | |
| navigateToInstance(parseInt(goToValue) - 1); | |
| } | |
| }); | |
| // Enter key on go to input | |
| goToInput.addEventListener('keypress', function (e) { | |
| if (e.key === 'Enter') { | |
| goToBtn.click(); | |
| } | |
| }); | |
| } | |
| // Keyboard navigation and shortcuts | |
| document.addEventListener('keydown', function (e) { | |
| // Only block navigation when in text input fields (not radio/checkbox) | |
| const inputType = e.target.getAttribute('type'); | |
| const isTextInput = e.target.tagName === 'TEXTAREA' || | |
| (e.target.tagName === 'INPUT' && inputType !== 'radio' && inputType !== 'checkbox'); | |
| if (isTextInput) { | |
| return; // Don't handle navigation when typing in text fields | |
| } | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| e.preventDefault(); | |
| navigateToPrevious(); | |
| break; | |
| case 'ArrowRight': | |
| e.preventDefault(); | |
| navigateToNext(); | |
| break; | |
| } | |
| }); | |
| // Keyboard shortcuts for checkboxes and radio buttons (matches base_template.html behavior) | |
| document.addEventListener('keyup', function (e) { | |
| // Don't handle when in text input fields (but allow radio/checkbox) | |
| const activeElement = document.activeElement; | |
| const activeId = activeElement.id; | |
| const activeType = activeElement.getAttribute('type'); | |
| const isTextInput = activeElement.tagName === 'TEXTAREA' || | |
| activeId === 'go_to' || | |
| (activeElement.tagName === 'INPUT' && activeType !== 'radio' && activeType !== 'checkbox'); | |
| if (isTextInput) { | |
| return; | |
| } | |
| const key = e.key.toLowerCase(); | |
| // Check checkboxes (match on data-key attribute, not value) | |
| const checkboxes = document.querySelectorAll('input[type="checkbox"]'); | |
| for (const checkbox of checkboxes) { | |
| const dataKey = checkbox.getAttribute('data-key'); | |
| if (dataKey && key === dataKey.toLowerCase()) { | |
| checkbox.checked = !checkbox.checked; | |
| // Trigger change event so annotation state gets updated | |
| checkbox.dispatchEvent(new Event('change', { bubbles: true })); | |
| if (checkbox.onclick) { | |
| checkbox.onclick.apply(checkbox); | |
| } | |
| return; | |
| } | |
| } | |
| // Check radio buttons (match on data-key attribute) | |
| const radios = document.querySelectorAll('input[type="radio"]'); | |
| for (const radio of radios) { | |
| const dataKey = radio.getAttribute('data-key'); | |
| if (dataKey && key === dataKey.toLowerCase()) { | |
| radio.checked = true; | |
| // Trigger change event so annotation state gets updated | |
| radio.dispatchEvent(new Event('change', { bubbles: true })); | |
| if (radio.onclick) { | |
| radio.onclick.apply(radio); | |
| } | |
| return; | |
| } | |
| } | |
| // Check pairwise tiles (binary mode) | |
| const pairwiseTiles = document.querySelectorAll('.pairwise-tile'); | |
| for (const tile of pairwiseTiles) { | |
| const dataKey = tile.getAttribute('data-key'); | |
| if (dataKey && key === dataKey) { | |
| selectPairwiseTile(tile); | |
| return; | |
| } | |
| } | |
| // Check pairwise tie/neither buttons | |
| const pairwiseButtons = document.querySelectorAll('.pairwise-tie-btn, .pairwise-neither-btn'); | |
| for (const btn of pairwiseButtons) { | |
| const dataKey = btn.getAttribute('data-key'); | |
| if (dataKey && key === dataKey) { | |
| selectPairwiseOption(btn); | |
| return; | |
| } | |
| } | |
| // Check BWS tiles (best: numbers, worst: letters) | |
| const bwsTiles = document.querySelectorAll('.bws-tile'); | |
| for (const tile of bwsTiles) { | |
| const dataKey = tile.getAttribute('data-key'); | |
| if (dataKey && key === dataKey) { | |
| selectBwsTile(tile); | |
| return; | |
| } | |
| } | |
| }); | |
| } | |
| /** | |
| * Initialize integration with the frontend span manager | |
| */ | |
| function initializeSpanManagerIntegration() { | |
| // Wait for span manager to be available | |
| const checkSpanManager = () => { | |
| if (window.spanManager && window.spanManager.isInitialized) { | |
| debugLog('Annotation.js: Span manager integration initialized'); | |
| setupSpanLabelSelector(); | |
| } else { | |
| setTimeout(checkSpanManager, 100); | |
| } | |
| }; | |
| checkSpanManager(); | |
| } | |
| /** | |
| * Setup span label selector interface | |
| * This function sets up the span label selection checkboxes and their event handlers | |
| */ | |
| function setupSpanLabelSelector() { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - ENTRY POINT'); | |
| // Find all span label checkboxes | |
| const spanLabelCheckboxes = document.querySelectorAll('input[name*="span_label"]'); | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Found span label checkboxes:', spanLabelCheckboxes.length); | |
| if (spanLabelCheckboxes.length === 0) { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - No span label checkboxes found'); | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - EXIT POINT (no checkboxes)'); | |
| return; | |
| } | |
| // Set up event listeners for each checkbox | |
| spanLabelCheckboxes.forEach((checkbox, index) => { | |
| debugLog(`🔍 [DEBUG] setupSpanLabelSelector() - Setting up checkbox ${index}:`, { | |
| name: checkbox.name, | |
| id: checkbox.id, | |
| value: checkbox.value | |
| }); | |
| // Add MutationObserver to track checkbox state changes | |
| const observer = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| if (mutation.type === 'attributes' && mutation.attributeName === 'checked') { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Checkbox checked attribute changed:', { | |
| id: checkbox.id, | |
| oldValue: mutation.oldValue, | |
| newValue: checkbox.checked, | |
| stack: new Error().stack | |
| }); | |
| } | |
| }); | |
| }); | |
| observer.observe(checkbox, { | |
| attributes: true, | |
| attributeOldValue: true, | |
| attributeFilter: ['checked'] | |
| }); | |
| // Override the checked property to track when it's set programmatically | |
| const originalChecked = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked'); | |
| Object.defineProperty(checkbox, 'checked', { | |
| get: function() { | |
| return originalChecked.get.call(this); | |
| }, | |
| set: function(value) { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Checkbox checked property being set:', { | |
| id: this.id, | |
| oldValue: originalChecked.get.call(this), | |
| newValue: value, | |
| stack: new Error().stack | |
| }); | |
| originalChecked.set.call(this, value); | |
| } | |
| }); | |
| // Add click event listener if not already present | |
| if (!checkbox.hasAttribute('data-span-label-setup')) { | |
| checkbox.addEventListener('change', function () { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Checkbox changed:', { | |
| name: this.name, | |
| checked: this.checked, | |
| value: this.value | |
| }); | |
| // Add stack trace to see what's calling this | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Change event stack trace:', new Error().stack); | |
| // Check if this change event was triggered by programmatic setting | |
| // If the checkbox was just set to checked by onlyOne, don't interfere | |
| if (this.checked && this.hasAttribute('data-just-checked')) { | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - Ignoring change event for just-checked checkbox'); | |
| this.removeAttribute('data-just-checked'); | |
| return; | |
| } | |
| // Note: We don't manage checkbox state here anymore because the onclick | |
| // handler (onlyOne function) already handles this correctly. | |
| // This change event is just for logging and any additional functionality | |
| // that might be needed in the future. | |
| }); | |
| // Mark as set up | |
| checkbox.setAttribute('data-span-label-setup', 'true'); | |
| } | |
| }); | |
| debugLog('🔍 [DEBUG] setupSpanLabelSelector() - EXIT POINT (setup complete)'); | |
| } | |
| /** | |
| * Check if current instance has span annotations | |
| */ | |
| function checkForSpanAnnotations() { | |
| if (!currentInstance || !currentInstance.annotation_scheme) { | |
| return false; | |
| } | |
| // Check if any annotation type is 'span' | |
| for (const schema of Object.values(currentInstance.annotation_scheme)) { | |
| if (schema.type === 'span') { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Get span labels from annotation scheme | |
| */ | |
| function getSpanLabelsFromScheme() { | |
| const labels = []; | |
| if (!currentInstance || !currentInstance.annotation_scheme) { | |
| return labels; | |
| } | |
| for (const [schemaName, schema] of Object.entries(currentInstance.annotation_scheme)) { | |
| if (schema.type === 'span' && schema.labels) { | |
| labels.push(...schema.labels); | |
| } | |
| } | |
| return labels; | |
| } | |
| /** | |
| * Load span annotations for current instance | |
| */ | |
| async function loadSpanAnnotations() { | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - ENTRY POINT'); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - currentInstance:', currentInstance); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - currentInstance.id:', currentInstance?.id); | |
| if (!currentInstance || !currentInstance.id) { | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - EXIT POINT (no currentInstance or id)'); | |
| return; | |
| } | |
| try { | |
| // Initialize span manager if not already done | |
| if (!window.spanManager) { | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - Initializing span manager'); | |
| initializeSpanManagerIntegration(); | |
| } | |
| // Wait for span manager to be ready | |
| await new Promise(resolve => { | |
| const checkSpanManager = () => { | |
| if (window.spanManager) { | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - Span manager ready'); | |
| resolve(); | |
| } else { | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - Span manager not ready, retrying...'); | |
| setTimeout(checkSpanManager, 100); | |
| } | |
| }; | |
| checkSpanManager(); | |
| }); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - About to call spanManager.loadAnnotations()'); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - Instance ID for API call:', currentInstance.id); | |
| // Load annotations for the current instance | |
| await window.spanManager.loadAnnotations(currentInstance.id); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - spanManager.loadAnnotations() completed'); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - EXIT POINT (success)'); | |
| } catch (error) { | |
| console.error('🔍 [DEBUG] loadSpanAnnotations() - Error loading span annotations:', error); | |
| debugLog('🔍 [DEBUG] loadSpanAnnotations() - EXIT POINT (error)'); | |
| } | |
| } | |
| async function loadCurrentInstance() { | |
| // Reset validation state when loading a new instance | |
| hasAttemptedForwardValidation = false; | |
| try { | |
| setLoading(true); | |
| showError(false); | |
| // DEBUG: Track overlays at start of instance loading | |
| debugTrackOverlays('START_LOAD_CURRENT_INSTANCE'); | |
| // Get current instance from server-rendered HTML | |
| const instanceTextElement = document.getElementById('instance-text'); | |
| const instanceIdElement = document.getElementById('instance_id'); | |
| if (!instanceTextElement) { | |
| throw new Error('Instance text element not found'); | |
| } | |
| // Get instance text from the rendered HTML (server-rendered) | |
| const instanceText = instanceTextElement.innerHTML; | |
| // Get instance ID from hidden input | |
| const instanceId = instanceIdElement ? instanceIdElement.value : null; | |
| debugLog(`🔍 [DEBUG] loadCurrentInstance: Read instance_id from DOM: '${instanceId}'`); | |
| if (!instanceText || instanceText.trim() === '') { | |
| showError(true, 'No instance text available'); | |
| return; | |
| } | |
| // Create current instance object from server-rendered data | |
| currentInstance = { | |
| id: instanceId, | |
| text: instanceTextElement.textContent || instanceTextElement.innerText, | |
| displayed_text: instanceText | |
| }; | |
| // Set global variable for span manager | |
| window.currentInstance = currentInstance; | |
| // Notify interaction tracker of instance change | |
| if (window.interactionTracker && instanceId) { | |
| window.interactionTracker.setInstanceId(instanceId); | |
| } | |
| // Get progress from the progress counter element | |
| const progressCounter = document.getElementById('progress-counter'); | |
| if (progressCounter) { | |
| const progressText = progressCounter.textContent; | |
| const match = progressText.match(/(\d+)\/(\d+)/); | |
| if (match) { | |
| const annotated = parseInt(match[1]); | |
| const total = parseInt(match[2]); | |
| userState = { | |
| assignments: { | |
| annotated: annotated, | |
| total: total | |
| }, | |
| annotations: { | |
| by_instance: {} | |
| } | |
| }; | |
| } | |
| } | |
| updateProgressDisplay(); | |
| updateInstanceDisplay(); | |
| // Clear browser-preserved form state before loading new annotations | |
| // This prevents image/audio/video annotations from persisting across instances | |
| clearAllFormInputs(); | |
| restoreSpanAnnotationsFromHTML(); | |
| loadAnnotations(); | |
| // Memos persist server-side and nav is a full reload, but if the | |
| // instance changes without a reload, refresh the memo panel so it | |
| // never shows another instance's notes. | |
| if (window.MemoPanel && typeof window.MemoPanel.reload === 'function') { | |
| window.MemoPanel.reload(); | |
| } | |
| generateAnnotationForms(); | |
| aiAssistantManger.getAiAssistantName(); | |
| // Populate pairwise item boxes after forms are generated | |
| populatePairwiseTileContent(); | |
| // Populate dynamic schema content (extractive_qa, text_edit, error_span, card_sort, conjoint) | |
| await populateDynamicSchemaContent(); | |
| // Codebook: reconcile codebook-backed forms (append codes added | |
| // mid-session) + restore runtime-code selections + the | |
| // stale-revision banner. MUST run AFTER generateAnnotationForms() | |
| // / populate* so the appended options aren't discarded by a | |
| // form rebuild. | |
| if (window.CodebookPanel && typeof window.CodebookPanel.onInstance === 'function') { | |
| window.CodebookPanel.onInstance(); | |
| } | |
| // Load span annotations | |
| debugLog('🔍 [DEBUG] loadCurrentInstance() - About to call loadSpanAnnotations()'); | |
| debugLog('🔍 [DEBUG] loadCurrentInstance() - currentInstance.id:', currentInstance?.id); | |
| await loadSpanAnnotations(); | |
| debugLog('🔍 [DEBUG] loadCurrentInstance() - loadSpanAnnotations() completed'); | |
| // Populate input values with existing annotations AFTER forms are generated | |
| setTimeout(() => { | |
| populateInputValues(); | |
| }, 0); | |
| } catch (error) { | |
| console.error('Error loading current instance:', error); | |
| showError(true, error.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function updateProgressDisplay() { | |
| // Progress is already displayed in the HTML template | |
| // No need to update it since it's server-rendered | |
| debugLog('Progress display updated from server-rendered HTML'); | |
| } | |
| function updateInstanceDisplay() { | |
| // Instance text is already displayed in the HTML template | |
| // Just ensure the instance_id is set correctly | |
| const instanceIdInput = document.getElementById('instance_id'); | |
| if (instanceIdInput && currentInstance && currentInstance.id) { | |
| const oldValue = instanceIdInput.value; | |
| instanceIdInput.value = currentInstance.id; | |
| debugLog(`🔍 [DEBUG] updateInstanceDisplay: Updated instance_id from '${oldValue}' to '${currentInstance.id}'`); | |
| // FIREFOX FIX: Force the input element to be updated in Firefox | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| if (isFirefox) { | |
| debugLog('🔍 [DEBUG] updateInstanceDisplay: Firefox detected - forcing input update'); | |
| // Method 1: Force a DOM update by temporarily changing and restoring the value | |
| const tempValue = instanceIdInput.value; | |
| instanceIdInput.value = ''; | |
| instanceIdInput.value = tempValue; | |
| // Method 2: Trigger input events to ensure Firefox recognizes the change | |
| instanceIdInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| instanceIdInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| // Method 3: Force a reflow | |
| instanceIdInput.offsetHeight; | |
| debugLog(`🔍 [DEBUG] updateInstanceDisplay: Firefox input update completed`); | |
| } | |
| } else { | |
| debugLog(`🔍 [DEBUG] updateInstanceDisplay: Could not update instance_id - input: ${!!instanceIdInput}, currentInstance: ${!!currentInstance}, currentInstance.id: ${currentInstance?.id}`); | |
| } | |
| debugLog('[DEBUG] updateInstanceDisplay: Instance display updated from server'); | |
| } | |
| // Add this function to clear all form inputs | |
| function clearAllFormInputs() { | |
| debugLog('🔍 Clearing all form inputs'); | |
| // Clear text inputs and textareas | |
| const textInputs = document.querySelectorAll('input[type="text"], textarea.annotation-input'); | |
| textInputs.forEach(input => { | |
| input.value = ''; | |
| }); | |
| // Clear radio buttons | |
| const radioInputs = document.querySelectorAll('input[type="radio"]'); | |
| radioInputs.forEach(input => { | |
| input.checked = false; | |
| }); | |
| // Clear checkboxes | |
| const checkboxInputs = document.querySelectorAll('input[type="checkbox"]'); | |
| checkboxInputs.forEach(input => { | |
| input.checked = false; | |
| }); | |
| // Clear sliders | |
| const sliderInputs = document.querySelectorAll('input[type="range"]'); | |
| sliderInputs.forEach(input => { | |
| input.value = input.getAttribute('min') || input.getAttribute('starting_value') || '0'; | |
| const valueDisplay = document.getElementById(`${input.name}-value`); | |
| if (valueDisplay) { | |
| valueDisplay.textContent = input.value; | |
| } | |
| }); | |
| // Clear select dropdowns | |
| const selectInputs = document.querySelectorAll('select.annotation-input'); | |
| selectInputs.forEach(input => { | |
| input.selectedIndex = 0; | |
| }); | |
| // Clear number inputs | |
| const numberInputs = document.querySelectorAll('input[type="number"].annotation-input'); | |
| numberInputs.forEach(input => { | |
| input.value = ''; | |
| }); | |
| // Clear hidden annotation inputs (BWS, triage, and other schemas using hidden inputs) | |
| // Remove data-modified flag and clear values unless server has set them | |
| const hiddenAnnotationInputs = document.querySelectorAll('input[type="hidden"].annotation-input'); | |
| hiddenAnnotationInputs.forEach(input => { | |
| if (input.getAttribute('data-server-set') !== 'true') { | |
| input.value = ''; | |
| input.removeAttribute('data-modified'); | |
| debugLog('🔍 Cleared hidden annotation input (browser-cached):', input.getAttribute('name')); | |
| } else { | |
| debugLog('🔍 Preserving server-provided hidden annotation input:', input.getAttribute('name')); | |
| } | |
| }); | |
| // Clear BWS tile selections | |
| document.querySelectorAll('.bws-tile.selected').forEach( | |
| tile => tile.classList.remove('selected') | |
| ); | |
| // Clear ranking visual state (reset order numbers) | |
| document.querySelectorAll('.ranking-list .ranking-item').forEach((item, idx) => { | |
| const rank = item.querySelector('.ranking-rank'); | |
| if (rank) rank.textContent = idx + 1; | |
| }); | |
| // Clear hierarchical multiselect checkboxes | |
| document.querySelectorAll('.hier-checkbox').forEach(cb => { | |
| cb.checked = false; | |
| }); | |
| document.querySelectorAll('.hier-selected-tags').forEach(tags => { | |
| tags.innerHTML = ''; | |
| }); | |
| // Clear trajectory eval visual state | |
| document.querySelectorAll('.traj-correctness-btn.selected').forEach(btn => { | |
| btn.classList.remove('selected'); | |
| }); | |
| document.querySelectorAll('.traj-error-details').forEach(div => { | |
| div.style.display = 'none'; | |
| }); | |
| document.querySelectorAll('.traj-step-status').forEach(el => { | |
| el.textContent = ''; | |
| el.className = 'traj-step-status'; | |
| }); | |
| if (window._trajState) { | |
| Object.keys(window._trajState).forEach(k => { | |
| window._trajState[k] = { steps: [] }; | |
| }); | |
| } | |
| // Clear trajectory edit (correction) visual state | |
| if (window._trajEditState) { | |
| Object.keys(window._trajEditState).forEach(k => { | |
| window._trajEditState[k] = { entries: {}, final_answer: null }; | |
| }); | |
| } | |
| // Clear hidden annotation data inputs (image/audio/video annotations) | |
| // BUT only if they don't have server-provided data (data-server-set="true") | |
| // This prevents browser form restoration from persisting annotations across instances | |
| // while preserving server-provided annotations when returning to an already-annotated instance | |
| const annotationDataInputs = document.querySelectorAll('input.annotation-data-input'); | |
| annotationDataInputs.forEach(input => { | |
| // Only clear if NOT set by the server (prevents clearing restored annotations) | |
| if (input.getAttribute('data-server-set') !== 'true') { | |
| input.value = ''; | |
| debugLog('🔍 Cleared annotation data input (browser-cached):', input.id); | |
| } else { | |
| debugLog('🔍 Preserving server-provided annotation data:', input.id); | |
| } | |
| }); | |
| // Reset image annotation managers if they exist | |
| // BUT only if there's no server-provided annotation data to load | |
| const imageContainers = document.querySelectorAll('.image-annotation-container'); | |
| imageContainers.forEach(container => { | |
| if (container.annotationManager && typeof container.annotationManager.clearAnnotations === 'function') { | |
| // Find the associated hidden input | |
| const schemaName = container.getAttribute('data-schema'); | |
| const hiddenInput = schemaName ? document.getElementById('input-' + schemaName) : null; | |
| // Only clear if there's no server-provided data | |
| if (!hiddenInput || hiddenInput.getAttribute('data-server-set') !== 'true') { | |
| container.annotationManager.clearAnnotations(); | |
| debugLog('🔍 Cleared image annotation manager for container (no server data)'); | |
| } else { | |
| debugLog('🔍 Preserving image annotation manager (has server data)'); | |
| } | |
| } | |
| }); | |
| debugLog('✅ All form inputs cleared'); | |
| } | |
| async function loadAnnotations() { | |
| try { | |
| debugLog('🔍 Loading annotations for instance:', currentInstance.id); | |
| // IMPORTANT: Read from server-rendered HTML attributes, NOT browser form state. | |
| // Firefox (and some other browsers) preserve form state across page navigations, | |
| // which can cause checkboxes from the previous instance to appear checked | |
| // even though the server didn't render them that way. | |
| currentAnnotations = {}; | |
| // Read checkbox state from HTML 'checked' ATTRIBUTE (not .checked property) | |
| // The server sets the 'checked' attribute on checkboxes that should be checked | |
| const checkboxInputs = document.querySelectorAll('input[type="checkbox"]'); | |
| checkboxInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| // Use hasAttribute('checked') to read server-rendered state | |
| const serverChecked = input.hasAttribute('checked'); | |
| // Sync the browser state to match server state (fixes Firefox form restoration) | |
| input.checked = serverChecked; | |
| if (schema && labelName && serverChecked) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Read radio button state from HTML 'checked' ATTRIBUTE | |
| const radioInputs = document.querySelectorAll('input[type="radio"]'); | |
| radioInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| // Use hasAttribute('checked') to read server-rendered state | |
| const serverChecked = input.hasAttribute('checked'); | |
| // Sync the browser state to match server state | |
| input.checked = serverChecked; | |
| if (schema && labelName && serverChecked) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Read text input state from HTML | |
| // For text inputs, the server sets the value attribute | |
| // For textareas, the server sets the content between the tags (textContent) | |
| const textInputs = document.querySelectorAll('input[type="text"], textarea.annotation-input'); | |
| textInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| // Read the server-rendered value: | |
| // - For <input type="text">: use getAttribute('value') which returns the HTML attribute | |
| // - For <textarea>: use textContent which returns the content between tags | |
| let serverValue; | |
| if (input.tagName.toLowerCase() === 'textarea') { | |
| serverValue = input.textContent || ''; | |
| } else { | |
| serverValue = input.getAttribute('value') || ''; | |
| } | |
| // Sync browser state to server state | |
| input.value = serverValue; | |
| if (schema && labelName && serverValue) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = serverValue; | |
| } | |
| }); | |
| // Read number input state from HTML 'value' ATTRIBUTE (constant_sum, etc.) | |
| const numberInputs = document.querySelectorAll('input[type="number"].annotation-input'); | |
| numberInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| const serverValue = input.getAttribute('value'); | |
| if (serverValue) { | |
| input.value = serverValue; | |
| } | |
| if (schema && labelName && serverValue) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = serverValue; | |
| } | |
| }); | |
| // Read slider state from HTML 'value' ATTRIBUTE | |
| const sliderInputs = document.querySelectorAll('input[type="range"]'); | |
| sliderInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| // Read from HTML attribute - server sets this for saved slider values | |
| const serverValue = input.getAttribute('value'); | |
| if (serverValue) { | |
| input.value = serverValue; | |
| } | |
| if (schema && labelName) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Read select dropdown state from server-rendered HTML | |
| // The server sets the 'selected' attribute on the appropriate option | |
| const selectInputs = document.querySelectorAll('select.annotation-input'); | |
| selectInputs.forEach(select => { | |
| const schema = select.getAttribute('schema'); | |
| const labelName = select.getAttribute('label_name'); | |
| // Find the option with 'selected' attribute (server-rendered) | |
| const selectedOption = select.querySelector('option[selected]'); | |
| if (selectedOption) { | |
| // Sync browser state to server state | |
| select.value = selectedOption.value; | |
| } | |
| if (schema && labelName && select.value) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = select.value; | |
| } | |
| }); | |
| // Read hidden input state from server-rendered HTML | |
| // The server sets the 'value' attribute AND 'data-server-set' flag via BeautifulSoup | |
| // for saved annotations. We MUST check for data-server-set to distinguish server-rendered | |
| // values from browser-cached form state — browsers restore hidden input values across | |
| // window.location.reload(), so getAttribute('value') alone is unreliable. | |
| const hiddenInputs = document.querySelectorAll('input[type="hidden"].annotation-input'); | |
| hiddenInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| const isServerSet = input.hasAttribute('data-server-set'); | |
| if (isServerSet) { | |
| // Server explicitly set this value — trust it | |
| const serverValue = input.getAttribute('value') || ''; | |
| input.value = serverValue; | |
| if (schema && labelName && serverValue) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = serverValue; | |
| } | |
| } else { | |
| // No server-set flag — clear any browser-cached value | |
| input.value = ''; | |
| } | |
| }); | |
| // Read annotation-data-input state (image/audio/video/tiered annotations) | |
| // These use a separate hidden input class and store serialized JSON data | |
| const annotationDataInputs = document.querySelectorAll('input.annotation-data-input'); | |
| annotationDataInputs.forEach(input => { | |
| if (input.name && input.value && input.getAttribute('data-server-set') === 'true') { | |
| const schema = input.name; | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema]['_data'] = input.value; | |
| } | |
| }); | |
| debugLog('🔍 Annotations loaded from DOM:', currentAnnotations); | |
| } catch (error) { | |
| console.error('❌ Error loading annotations:', error); | |
| currentAnnotations = {}; | |
| } | |
| } | |
| function generateAnnotationForms() { | |
| const formsContainer = document.getElementById('annotation-forms'); | |
| // The server generates the forms, so we just need to set up event listeners | |
| // The forms are already in the HTML from server-side generation | |
| setupInputEventListeners(); | |
| validateRequiredFields(); | |
| } | |
| async function saveAnnotations() { | |
| if (!currentInstance || !currentInstance.id) { | |
| return; | |
| } | |
| // Track save event | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackSave(currentInstance.id); | |
| } | |
| try { | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| }; | |
| // Add API key if available | |
| if (window.config && window.config.api_key) { | |
| headers['X-API-Key'] = window.config.api_key; | |
| } | |
| // Sync currentAnnotations from DOM to ensure we have the latest state | |
| // This handles cases where change events may not have fired (e.g., JS clicks) | |
| syncAnnotationsFromDOM(); | |
| // Save both label and span annotations via /updateinstance | |
| const spanAnnotations = extractSpanAnnotationsFromDOM(); | |
| debugLog('[DEBUG] saveAnnotations: spanAnnotations to send:', spanAnnotations); | |
| // Transform currentAnnotations to the format expected by /updateinstance | |
| const labelAnnotations = {}; | |
| for (const [schema, labels] of Object.entries(currentAnnotations)) { | |
| for (const [label, value] of Object.entries(labels)) { | |
| const key = `${schema}:${label}`; | |
| labelAnnotations[key] = value; | |
| } | |
| } | |
| // Also collect data from hidden annotation inputs (image/audio/video annotations) | |
| const hiddenInputs = document.querySelectorAll('.annotation-data-input'); | |
| hiddenInputs.forEach(input => { | |
| if (input.name && input.value) { | |
| // Store the raw JSON value with schema name as key | |
| // Use ::: separator to match the format used by other annotation types | |
| const key = `${input.name}:::_data`; | |
| labelAnnotations[key] = input.value; | |
| debugLog('[DEBUG] saveAnnotations: collected hidden input:', input.name, '=', input.value.substring(0, 100) + '...'); | |
| } | |
| }); | |
| const response = await fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify({ | |
| instance_id: currentInstance.id, | |
| annotations: labelAnnotations, | |
| span_annotations: spanAnnotations | |
| }) | |
| }); | |
| if (response.ok) { | |
| // Get response text first to debug any JSON parsing issues | |
| const responseText = await response.text(); | |
| try { | |
| const result = JSON.parse(responseText); | |
| debugLog('[DEBUG] saveAnnotations: annotations saved:', result); | |
| handleQualityControlResponse(result); | |
| } catch (jsonError) { | |
| console.error('[DEBUG] saveAnnotations: JSON parse error:', jsonError); | |
| console.error('[DEBUG] saveAnnotations: Response text (first 500 chars):', responseText.substring(0, 500)); | |
| // Don't throw - the save may have succeeded even if response parsing failed | |
| } | |
| } else { | |
| console.warn('[DEBUG] saveAnnotations: failed to save annotations:', await response.text()); | |
| return false; | |
| } | |
| return true; | |
| } catch (error) { | |
| console.error('Error saving annotations:', error); | |
| showError(true, 'Failed to save annotations: ' + error.message); | |
| return false; | |
| } | |
| } | |
| async function navigateToPrevious() { | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - ENTRY POINT'); | |
| deepDebugState.navigationCalls++; | |
| // Reset validation state on backward navigation | |
| hasAttemptedForwardValidation = false; | |
| logDeepDebug('navigateToPrevious_start', { | |
| currentInstanceId: currentInstance?.id, | |
| overlayCount: getCurrentOverlayCount() | |
| }); | |
| if (isLoading) { | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Navigation blocked, still loading'); | |
| return; | |
| } | |
| setLoading(true); | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Loading set to true'); | |
| // Track navigation event | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackNavigation('prev', currentInstance?.id, null); | |
| } | |
| try { | |
| // Flush any pending debounced save before the explicit save | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = null; | |
| // Save annotations before navigating away | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Saving annotations before navigation'); | |
| const saveSucceeded = await saveAnnotations(); | |
| if (saveSucceeded === false) { | |
| showNotification('Failed to save annotations. Please try again.', 'error'); | |
| setLoading(false); | |
| return; | |
| } | |
| // FIREFOX FIX: Force overlay cleanup before navigation | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Is Firefox:', isFirefox); | |
| if (isFirefox) { | |
| debugLog('[DEEP DEBUG NAV] Firefox detected - forcing overlay cleanup before navigation'); | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| if (spanOverlays) { | |
| const beforeCount = spanOverlays.children.length; | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Before Firefox cleanup:', beforeCount, 'overlays'); | |
| // Remove all overlays individually | |
| while (spanOverlays.firstChild) { | |
| const child = spanOverlays.firstChild; | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Removing overlay child:', child.className, child.id); | |
| // Track overlay removal for debugging | |
| if (typeof trackOverlayRemoval === 'function') { | |
| trackOverlayRemoval(child, 'navigateToPrevious Firefox cleanup'); | |
| } | |
| spanOverlays.removeChild(child); | |
| } | |
| // Force reflow | |
| spanOverlays.offsetHeight; | |
| const afterCount = spanOverlays.children.length; | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - After Firefox cleanup:', afterCount, 'overlays'); | |
| // Double-check cleanup | |
| const remainingOverlays = document.querySelectorAll('.span-overlay'); | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Remaining overlays via querySelectorAll:', remainingOverlays.length); | |
| if (remainingOverlays.length > 0) { | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - WARNING: Overlays still exist after cleanup!'); | |
| remainingOverlays.forEach((overlay, index) => { | |
| debugLog(`[DEEP DEBUG NAV] navigateToPrevious - Remaining overlay ${index}:`, overlay.className, overlay.id); | |
| }); | |
| } | |
| } else { | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - No span-overlays container found'); | |
| } | |
| } | |
| // Use the correct endpoint and payload for navigation | |
| const response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| action: 'prev_instance', | |
| instance_id: currentInstance?.id | |
| }) | |
| }); | |
| if (response.ok) { | |
| debugLog('[DEEP DEBUG NAV] navigateToPrevious - Navigation successful, reloading page'); | |
| if (window.spanManager && typeof window.spanManager.onInstanceChange === 'function') { | |
| window.spanManager.onInstanceChange(currentInstance?.id); | |
| } | |
| logDeepDebug('navigateToPrevious_success', { | |
| currentInstanceId: currentInstance?.id, | |
| overlayCount: getCurrentOverlayCount() | |
| }); | |
| // Add a small delay to ensure span manager operations complete before reload | |
| setTimeout(() => { | |
| window.location.reload(); | |
| }, 100); | |
| } else { | |
| console.error('[DEEP DEBUG NAV] navigateToPrevious - Navigation failed:', response.status); | |
| setLoading(false); | |
| } | |
| } catch (error) { | |
| console.error('[DEEP DEBUG NAV] navigateToPrevious - Navigation error:', error); | |
| setLoading(false); | |
| } | |
| } | |
| /** | |
| * Handle non-OK navigation responses from the server. | |
| * Shows a toast notification for validation errors (400). | |
| */ | |
| async function handleNavigationResponseError(response) { | |
| console.error('[NAV] Navigation failed:', response.status); | |
| if (response.status === 400) { | |
| try { | |
| const data = await response.json(); | |
| if (data.status === 'validation_error') { | |
| const schemas = (data.unsatisfied_schemas || []).join(', '); | |
| showNotification(data.message || `Required annotations not completed: ${schemas}`, 'error'); | |
| // Re-run validation to highlight unfilled fields | |
| hasAttemptedForwardValidation = true; | |
| validateRequiredFields({ showErrors: true }); | |
| return; | |
| } | |
| } catch (e) { | |
| // Not JSON, fall through | |
| } | |
| } | |
| showNotification('Navigation failed. Please try again.', 'error'); | |
| } | |
| async function navigateToNext() { | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - ENTRY POINT'); | |
| deepDebugState.navigationCalls++; | |
| logDeepDebug('navigateToNext_start', { | |
| currentInstanceId: currentInstance?.id, | |
| overlayCount: getCurrentOverlayCount() | |
| }); | |
| if (isLoading) { | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Navigation blocked, still loading'); | |
| return; | |
| } | |
| // Client-side required field validation | |
| hasAttemptedForwardValidation = true; | |
| if (!validateRequiredFields({ showErrors: true })) { | |
| debugLog('[NAV] navigateToNext - blocked by client-side validation'); | |
| return; | |
| } | |
| setLoading(true); | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Loading set to true'); | |
| // Track navigation event | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackNavigation('next', currentInstance?.id, null); | |
| } | |
| try { | |
| // Flush any pending debounced save before the explicit save | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = null; | |
| // Save annotations before navigating away | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Saving annotations before navigation'); | |
| const saveSucceeded = await saveAnnotations(); | |
| if (saveSucceeded === false) { | |
| showNotification('Failed to save annotations. Please try again.', 'error'); | |
| setLoading(false); | |
| return; | |
| } | |
| // FIREFOX FIX: Force overlay cleanup before navigation | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Is Firefox:', isFirefox); | |
| if (isFirefox) { | |
| debugLog('[DEEP DEBUG NAV] Firefox detected - forcing overlay cleanup before navigation'); | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| if (spanOverlays) { | |
| const beforeCount = spanOverlays.children.length; | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Before Firefox cleanup:', beforeCount, 'overlays'); | |
| // Remove all overlays individually | |
| while (spanOverlays.firstChild) { | |
| const child = spanOverlays.firstChild; | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Removing overlay child:', child.className, child.id); | |
| // Track overlay removal for debugging | |
| if (typeof trackOverlayRemoval === 'function') { | |
| trackOverlayRemoval(child, 'navigateToNext Firefox cleanup'); | |
| } | |
| spanOverlays.removeChild(child); | |
| } | |
| // Force reflow | |
| spanOverlays.offsetHeight; | |
| const afterCount = spanOverlays.children.length; | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - After Firefox cleanup:', afterCount, 'overlays'); | |
| // Double-check cleanup | |
| const remainingOverlays = document.querySelectorAll('.span-overlay'); | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Remaining overlays via querySelectorAll:', remainingOverlays.length); | |
| if (remainingOverlays.length > 0) { | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - WARNING: Overlays still exist after cleanup!'); | |
| remainingOverlays.forEach((overlay, index) => { | |
| debugLog(`[DEEP DEBUG NAV] navigateToNext - Remaining overlay ${index}:`, overlay.className, overlay.id); | |
| }); | |
| } | |
| } else { | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - No span-overlays container found'); | |
| } | |
| } | |
| // Use the correct endpoint and payload for navigation | |
| const response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| action: 'next_instance', | |
| instance_id: currentInstance?.id | |
| }) | |
| }); | |
| if (response.ok) { | |
| debugLog('[DEEP DEBUG NAV] navigateToNext - Navigation successful, reloading page'); | |
| if (window.spanManager && typeof window.spanManager.onInstanceChange === 'function') { | |
| window.spanManager.onInstanceChange(currentInstance?.id); | |
| } | |
| logDeepDebug('navigateToNext_success', { | |
| currentInstanceId: currentInstance?.id, | |
| overlayCount: getCurrentOverlayCount() | |
| }); | |
| // Add a small delay to ensure span manager operations complete before reload | |
| setTimeout(() => { | |
| window.location.reload(); | |
| }, 100); | |
| } else { | |
| handleNavigationResponseError(response); | |
| setLoading(false); | |
| } | |
| } catch (error) { | |
| console.error('[DEEP DEBUG NAV] navigateToNext - Navigation error:', error); | |
| setLoading(false); | |
| } | |
| } | |
| async function navigateToInstance(instanceIndex) { | |
| if (isLoading) { | |
| return; | |
| } | |
| // Client-side validation for forward navigation | |
| const currentIdx = currentInstance ? currentInstance.index : 0; | |
| if (instanceIndex > currentIdx) { | |
| hasAttemptedForwardValidation = true; | |
| if (!validateRequiredFields({ showErrors: true })) { | |
| debugLog('[NAV] navigateToInstance - blocked by client-side validation'); | |
| return; | |
| } | |
| } | |
| try { | |
| setLoading(true); | |
| // Flush any pending debounced save before the explicit save | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = null; | |
| // Save annotations before navigating away (same as navigateToPrevious/Next) | |
| debugLog('[DEEP DEBUG NAV] navigateToInstance - Saving annotations before navigation'); | |
| const saveSucceeded = await saveAnnotations(); | |
| if (saveSucceeded === false) { | |
| showNotification('Failed to save annotations. Please try again.', 'error'); | |
| setLoading(false); | |
| return; | |
| } | |
| // DEBUG: Track overlays before navigation | |
| debugTrackOverlays('BEFORE_GO_TO_NAVIGATION', currentInstance?.id); | |
| // FIREFOX FIX: Force overlay cleanup before navigation | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Is Firefox:', isFirefox); | |
| if (isFirefox) { | |
| debugLog('🔍 [DEBUG] Firefox detected - forcing overlay cleanup before navigation'); | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| if (spanOverlays) { | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Before Firefox cleanup:', spanOverlays.children.length, 'overlays'); | |
| // Remove all overlays individually | |
| while (spanOverlays.firstChild) { | |
| const child = spanOverlays.firstChild; | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Removing overlay child:', child.className, child.id); | |
| // Track overlay removal for debugging | |
| if (typeof trackOverlayRemoval === 'function') { | |
| trackOverlayRemoval(child, 'navigateToInstance Firefox cleanup'); | |
| } | |
| spanOverlays.removeChild(child); | |
| } | |
| // Force reflow | |
| spanOverlays.offsetHeight; | |
| debugLog('🔍 [DEBUG] navigateToInstance() - After Firefox cleanup:', spanOverlays.children.length, 'overlays'); | |
| } else { | |
| debugLog('🔍 [DEBUG] navigateToInstance() - No span-overlays container found'); | |
| } | |
| } | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| }; | |
| if (window.config.api_key) { | |
| headers['X-API-Key'] = window.config.api_key; | |
| } | |
| const response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify({ | |
| action: 'go_to', | |
| go_to: instanceIndex | |
| }) | |
| }); | |
| if (response.ok) { | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Navigation successful, about to reload page'); | |
| // DEBUG: Clear overlays before reload | |
| const spanOverlays = document.getElementById('span-overlays'); | |
| if (spanOverlays) { | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Before clearing overlays:', spanOverlays.children.length, 'overlays'); | |
| debugLog('🔍 [DEBUG] navigateToInstance() - Clearing span overlays before page reload'); | |
| spanOverlays.innerHTML = ''; | |
| debugLog('🔍 [DEBUG] navigateToInstance() - After clearing overlays:', spanOverlays.children.length, 'overlays'); | |
| debugVerifyOverlayCleanup(); | |
| } else { | |
| debugLog('🔍 [DEBUG] navigateToInstance() - No span-overlays container found'); | |
| } | |
| // Reload the page to get the new instance data from the server | |
| window.location.reload(); | |
| } else { | |
| await handleNavigationResponseError(response); | |
| } | |
| } catch (error) { | |
| console.error('Error navigating to instance:', error); | |
| showError(true, error.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function validateRequiredFields(options) { | |
| // If user has already attempted forward validation, always show errors | |
| // so they get real-time feedback as they fill in fields | |
| const showErrors = (options && options.showErrors) || hasAttemptedForwardValidation; | |
| // Check all inputs with validation="required" or validation="required_label" | |
| const requiredInputs = document.querySelectorAll( | |
| 'input[validation="required"], input[validation="required_label"], ' + | |
| 'select[validation="required"], textarea[validation="required"]' | |
| ); | |
| let allRequiredFilled = true; | |
| const unfilledSchemas = []; | |
| // Group inputs by their parent form's schema name | |
| const formGroups = {}; | |
| requiredInputs.forEach(input => { | |
| const form = input.closest('.annotation-form'); | |
| const schemaName = form ? (form.getAttribute('data-schema-name') || form.id) : null; | |
| if (!schemaName) return; | |
| if (!formGroups[schemaName]) { | |
| formGroups[schemaName] = { form: form, radios: {}, others: [] }; | |
| } | |
| if (input.type === 'radio') { | |
| const name = input.name; | |
| if (!formGroups[schemaName].radios[name]) { | |
| formGroups[schemaName].radios[name] = []; | |
| } | |
| formGroups[schemaName].radios[name].push(input); | |
| } else { | |
| formGroups[schemaName].others.push(input); | |
| } | |
| }); | |
| // Check each schema's required inputs | |
| for (const [schemaName, group] of Object.entries(formGroups)) { | |
| let schemaFilled = true; | |
| // Check radio groups | |
| for (const [name, inputs] of Object.entries(group.radios)) { | |
| if (!inputs.some(input => input.checked)) { | |
| schemaFilled = false; | |
| break; | |
| } | |
| } | |
| // Check other inputs (textbox, select, range, etc.) | |
| for (const input of group.others) { | |
| if (input.type === 'range') { | |
| // Sliders: check if user has interacted (data-modified attribute) | |
| if (input.getAttribute('data-modified') !== 'true') { | |
| schemaFilled = false; | |
| break; | |
| } | |
| } else if (!input.value || input.value.trim() === '') { | |
| schemaFilled = false; | |
| break; | |
| } | |
| } | |
| if (!schemaFilled) { | |
| allRequiredFilled = false; | |
| const legend = group.form.querySelector('legend'); | |
| const label = legend ? legend.textContent.trim() : schemaName; | |
| unfilledSchemas.push({ name: schemaName, label: label }); | |
| } | |
| // Only show visual feedback if user has attempted forward navigation | |
| if (showErrors && group.form) { | |
| group.form.classList.toggle('required-unfilled', !schemaFilled); | |
| } | |
| } | |
| // Only show error messages after first forward attempt | |
| if (showErrors) { | |
| updateRequiredFieldsError(unfilledSchemas); | |
| } | |
| return allRequiredFilled; | |
| } | |
| function updateRequiredFieldsError(unfilledSchemas) { | |
| let errorDiv = document.getElementById('required-fields-error'); | |
| if (unfilledSchemas.length === 0) { | |
| if (errorDiv) { | |
| errorDiv.style.display = 'none'; | |
| } | |
| return; | |
| } | |
| // Create error div if it doesn't exist | |
| if (!errorDiv) { | |
| errorDiv = document.createElement('div'); | |
| errorDiv.id = 'required-fields-error'; | |
| errorDiv.className = 'required-fields-error'; | |
| const navDiv = document.querySelector('.potato-nav'); | |
| if (navDiv) { | |
| navDiv.parentNode.insertBefore(errorDiv, navDiv); | |
| } | |
| } | |
| const labels = unfilledSchemas.map(s => `<strong>${s.label}</strong>`).join(', '); | |
| const plural = unfilledSchemas.length > 1; | |
| errorDiv.innerHTML = `<i class="fas fa-exclamation-circle"></i> Please answer the required question${plural ? 's' : ''}: ${labels}`; | |
| errorDiv.style.display = 'block'; | |
| } | |
| function setLoading(loading) { | |
| isLoading = loading; | |
| const loadingState = document.getElementById('loading-state'); | |
| const mainContent = document.getElementById('main-content'); | |
| const prevBtn = document.getElementById('prev-btn'); | |
| const nextBtn = document.getElementById('next-btn'); | |
| if (loading) { | |
| loadingState.style.display = 'block'; | |
| mainContent.style.display = 'none'; | |
| if (prevBtn) prevBtn.disabled = true; | |
| nextBtn.disabled = true; | |
| } else { | |
| loadingState.style.display = 'none'; | |
| mainContent.style.display = 'block'; | |
| if (prevBtn) prevBtn.disabled = false; | |
| // Re-enable next button, then run validation which may re-disable it | |
| // if required fields are unfilled | |
| nextBtn.disabled = false; | |
| validateRequiredFields(); | |
| } | |
| } | |
| function showError(show, message = '', options = {}) { | |
| const errorState = document.getElementById('error-state'); | |
| const errorMessage = document.getElementById('error-message-text'); | |
| const mainContent = document.getElementById('main-content'); | |
| const retryBtn = document.getElementById('error-retry-btn'); | |
| const doneLink = document.getElementById('error-done-link'); | |
| if (show) { | |
| errorState.style.display = 'block'; | |
| mainContent.style.display = 'none'; | |
| errorMessage.textContent = message; | |
| // For permanent blocks (e.g., attention check failures), hide retry and show finish link | |
| if (options.permanent) { | |
| if (retryBtn) retryBtn.style.display = 'none'; | |
| if (doneLink) doneLink.style.display = 'inline-flex'; | |
| } else { | |
| if (retryBtn) retryBtn.style.display = ''; | |
| if (doneLink) doneLink.style.display = 'none'; | |
| } | |
| } else { | |
| errorState.style.display = 'none'; | |
| mainContent.style.display = 'block'; | |
| } | |
| } | |
| // Utility functions for annotation handling | |
| function updateAnnotation(schema, label, value) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][label] = value; | |
| } | |
| /** | |
| * Sync currentAnnotations from DOM to ensure we capture all current input states. | |
| * This is needed before saving because change events may not fire for JS-triggered clicks. | |
| */ | |
| function syncAnnotationsFromDOM() { | |
| // Sync checkboxes | |
| const checkboxes = document.querySelectorAll('input[type="checkbox"].annotation-input'); | |
| checkboxes.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName) { | |
| if (input.checked) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } else { | |
| // Remove unchecked checkboxes | |
| if (currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| delete currentAnnotations[schema][labelName]; | |
| if (Object.keys(currentAnnotations[schema]).length === 0) { | |
| delete currentAnnotations[schema]; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Sync radio buttons — clear schemas first (radios are mutually exclusive) | |
| const radios = document.querySelectorAll('input[type="radio"].annotation-input'); | |
| const radioSchemas = new Set(); | |
| radios.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| if (schema) radioSchemas.add(schema); | |
| }); | |
| radioSchemas.forEach(schema => { delete currentAnnotations[schema]; }); | |
| radios.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && input.checked) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Sync text inputs | |
| const textInputs = document.querySelectorAll('input[type="text"].annotation-input, textarea.annotation-input'); | |
| textInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && input.value) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Sync sliders | |
| const sliders = document.querySelectorAll('input[type="range"].annotation-input'); | |
| sliders.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Sync select dropdowns | |
| const selects = document.querySelectorAll('select.annotation-input'); | |
| selects.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && input.value) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Sync number inputs | |
| const numberInputs = document.querySelectorAll('input[type="number"].annotation-input'); | |
| numberInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && input.value) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| // Sync hidden inputs (used by BWS, triage, and other custom schemas) | |
| // IMPORTANT: Only include hidden inputs explicitly set by user interaction (data-modified) | |
| // or server-side annotation restore (data-server-set). Browsers restore hidden input .value | |
| // across page reloads (form state caching), which would otherwise leak annotations between instances. | |
| const hiddenInputs = document.querySelectorAll('input[type="hidden"].annotation-input'); | |
| hiddenInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| const isModified = input.hasAttribute('data-modified') || input.hasAttribute('data-server-set'); | |
| if (schema && labelName && input.value && isModified) { | |
| if (!currentAnnotations[schema]) { | |
| currentAnnotations[schema] = {}; | |
| } | |
| currentAnnotations[schema][labelName] = input.value; | |
| } | |
| }); | |
| debugLog('[DEBUG] syncAnnotationsFromDOM: synced annotations:', currentAnnotations); | |
| } | |
| // Function to handle "None" option in multiselect annotations | |
| function whetherNone(checkbox) { | |
| // This function is used to uncheck all the other labels when "None" is checked | |
| // and vice versa | |
| var x = document.getElementsByClassName(checkbox.className); | |
| var i; | |
| for (i = 0; i < x.length; i++) { | |
| if (checkbox.value == "None" && x[i].value != "None") x[i].checked = false; | |
| if (checkbox.value != "None" && x[i].value == "None") x[i].checked = false; | |
| } | |
| // Also trigger the input change handler for the current checkbox | |
| handleInputChange(checkbox); | |
| } | |
| // Input event handling functions | |
| function setupInputEventListeners() { | |
| // Set up event listeners for all annotation inputs | |
| const inputs = document.querySelectorAll('.annotation-input'); | |
| inputs.forEach(input => { | |
| const inputType = input.type; | |
| const tagName = input.tagName.toLowerCase(); | |
| if (inputType === 'text' || tagName === 'textarea') { | |
| // Text inputs and textareas - debounced saving | |
| let timer; | |
| input.addEventListener('input', function (event) { | |
| clearTimeout(timer); | |
| timer = setTimeout(() => { | |
| handleInputChange(event.target); | |
| }, 1000); | |
| }); | |
| debugLog(`Set up event listener for ${tagName} element:`, input.id); | |
| } else if (inputType === 'radio' || inputType === 'checkbox') { | |
| // Radio/checkbox inputs - immediate saving | |
| input.addEventListener('change', function (event) { | |
| handleInputChange(event.target); | |
| }); | |
| } else if (inputType === 'range') { | |
| // Slider inputs - immediate saving with value display | |
| input.addEventListener('input', function (event) { | |
| const valueDisplay = document.getElementById(`${input.name}-value`); | |
| if (valueDisplay) { | |
| valueDisplay.textContent = event.target.value; | |
| } | |
| handleInputChange(event.target); | |
| }); | |
| } else if (tagName === 'select') { | |
| // Select inputs - immediate saving | |
| input.addEventListener('change', function (event) { | |
| handleInputChange(event.target); | |
| }); | |
| } else if (inputType === 'number') { | |
| // Number inputs - debounced saving | |
| let timer; | |
| input.addEventListener('input', function (event) { | |
| clearTimeout(timer); | |
| timer = setTimeout(() => { | |
| handleInputChange(event.target); | |
| }, 1000); | |
| }); | |
| } else if (inputType === 'hidden') { | |
| // Hidden inputs (used by triage and other custom schemas) - listen for change events | |
| input.addEventListener('change', function (event) { | |
| handleInputChange(event.target); | |
| }); | |
| debugLog(`Set up event listener for hidden input:`, input.id); | |
| } | |
| }); | |
| } | |
| function handleInputChange(element) { | |
| const schema = element.getAttribute('schema'); | |
| const labelName = element.getAttribute('label_name'); | |
| const inputType = element.type; | |
| const tagName = element.tagName.toLowerCase(); | |
| debugLog(`handleInputChange called for ${tagName} element:`, element.id, 'schema:', schema, 'label:', labelName); | |
| if (!schema || !labelName) { | |
| console.warn('Missing schema or label_name for input:', element); | |
| return; | |
| } | |
| // Validate required fields after input change | |
| validateRequiredFields(); | |
| let value; | |
| if (inputType === 'radio') { | |
| // For radio buttons, only save if checked | |
| if (element.checked) { | |
| const oldValue = currentAnnotations[schema] ? currentAnnotations[schema][labelName] : null; | |
| // Radio buttons are mutually exclusive — clear old entries for this schema | |
| currentAnnotations[schema] = {}; | |
| value = element.value; | |
| // Track radio button selection | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackAnnotationChange(schema, labelName, 'select', oldValue, value, 'user'); | |
| } | |
| } else { | |
| return; // Don't save unchecked radio buttons | |
| } | |
| } else if (inputType === 'checkbox') { | |
| // For checkboxes, save the checked state | |
| if (element.checked) { | |
| value = element.value; | |
| // Track annotation selection | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackAnnotationChange(schema, labelName, 'select', null, value, 'user'); | |
| } | |
| } else { | |
| // For unchecked checkboxes, remove the annotation or set to false | |
| const oldValue = currentAnnotations[schema] ? currentAnnotations[schema][labelName] : null; | |
| if (currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| delete currentAnnotations[schema][labelName]; | |
| // If the schema is empty, remove it too | |
| if (Object.keys(currentAnnotations[schema]).length === 0) { | |
| delete currentAnnotations[schema]; | |
| } | |
| } | |
| debugLog(`Removed annotation: ${schema}.${labelName}`); | |
| // Track annotation deselection | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackAnnotationChange(schema, labelName, 'deselect', oldValue, null, 'user'); | |
| } | |
| // Auto-save the removal | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = setTimeout(() => { | |
| saveAnnotations(); | |
| }, 500); | |
| return; | |
| } | |
| } else { | |
| // For text inputs, save the value | |
| const oldValue = currentAnnotations[schema] ? currentAnnotations[schema][labelName] : null; | |
| value = element.value; | |
| // Track text input change | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackAnnotationChange(schema, labelName, 'update', oldValue, value, 'user'); | |
| } | |
| } | |
| // Update the current annotations | |
| updateAnnotation(schema, labelName, value); | |
| debugLog(`Updated annotation: ${schema}.${labelName} = ${value}`); | |
| // Evaluate display logic for conditional schemas | |
| if (displayLogicManager) { | |
| displayLogicManager.evaluateForSchema(schema); | |
| } | |
| // Auto-save | |
| clearTimeout(textSaveTimer); | |
| textSaveTimer = setTimeout(() => { | |
| saveAnnotations(); | |
| }, 500); | |
| } | |
| function populateInputValues() { | |
| if (!currentAnnotations) return; | |
| debugLog('🔍 Populating input values with annotations:', currentAnnotations); | |
| // Populate text inputs and textareas | |
| const textInputs = document.querySelectorAll('input[type="text"], textarea.annotation-input'); | |
| debugLog('🔍 Found text inputs and textareas:', textInputs.length); | |
| textInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| debugLog('🔍 Checking input:', input.id, 'schema:', schema, 'label:', labelName); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| input.value = currentAnnotations[schema][labelName]; | |
| debugLog(`✅ Populated ${input.tagName} ${input.id} with value:`, currentAnnotations[schema][labelName]); | |
| } else { | |
| debugLog(`❌ Could not populate ${input.tagName} ${input.id}:`, { | |
| hasSchema: !!schema, | |
| hasLabelName: !!labelName, | |
| hasSchemaInAnnotations: !!(currentAnnotations[schema]), | |
| hasLabelInSchema: !!(currentAnnotations[schema] && currentAnnotations[schema][labelName]) | |
| }); | |
| } | |
| }); | |
| // Populate radio buttons | |
| const radioInputs = document.querySelectorAll('input[type="radio"]'); | |
| radioInputs.forEach(input => { | |
| // Codebook restore (restoreRuntimeSelections) authoritatively | |
| // sets runtime-code inputs and marks them data-server-set. Don't | |
| // override those here — our async fetch and theirs race, and an | |
| // unconditional reset would clobber a just-restored runtime code. | |
| if (input.getAttribute('data-server-set') === 'true') return; | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| input.checked = (currentAnnotations[schema][labelName] === input.value); | |
| debugLog(`Populated radio ${input.id}: ${input.checked ? 'checked' : 'unchecked'}`); | |
| } | |
| }); | |
| // Populate checkboxes | |
| const checkboxInputs = document.querySelectorAll('input[type="checkbox"]'); | |
| checkboxInputs.forEach(input => { | |
| // See the radio note above: a checkbox the codebook restore | |
| // already owns (data-server-set) must not be unconditionally | |
| // reset here — the `input.checked = hasAnnotation` below would | |
| // force-uncheck a restored runtime code whose key isn't in | |
| // currentAnnotations, which is the nav-back persistence race. | |
| if (input.getAttribute('data-server-set') === 'true') return; | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema]) { | |
| // For checkboxes, check if the value exists in the annotations | |
| const hasAnnotation = currentAnnotations[schema][labelName] === input.value; | |
| input.checked = hasAnnotation; | |
| debugLog(`Populated checkbox ${input.id}: ${hasAnnotation ? 'checked' : 'unchecked'}`); | |
| } | |
| }); | |
| // Populate sliders | |
| const sliderInputs = document.querySelectorAll('input[type="range"]'); | |
| sliderInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| input.value = currentAnnotations[schema][labelName]; | |
| const valueDisplay = document.getElementById(`${input.name}-value`); | |
| if (valueDisplay) { | |
| valueDisplay.textContent = currentAnnotations[schema][labelName]; | |
| } | |
| // Dispatch input event to trigger display updates (constant_sum, VAS, etc.) | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| debugLog(`Populated slider ${input.id} with value:`, currentAnnotations[schema][labelName]); | |
| } | |
| }); | |
| // Populate select dropdowns | |
| const selectInputs = document.querySelectorAll('select.annotation-input'); | |
| selectInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| input.value = currentAnnotations[schema][labelName]; | |
| debugLog(`Populated select ${input.id} with value:`, currentAnnotations[schema][labelName]); | |
| } | |
| }); | |
| // Populate number inputs | |
| const numberInputs = document.querySelectorAll('input[type="number"].annotation-input'); | |
| numberInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| input.value = currentAnnotations[schema][labelName]; | |
| // Dispatch input event to trigger constant_sum display updates | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| debugLog(`Populated number ${input.id} with value:`, currentAnnotations[schema][labelName]); | |
| } | |
| }); | |
| // Populate pairwise annotations | |
| restorePairwiseAnnotations(); | |
| // Populate BWS annotations | |
| restoreBwsAnnotations(); | |
| // Populate ranking annotations | |
| restoreRankingAnnotations(); | |
| // Populate hierarchical multiselect annotations | |
| restoreHierarchicalAnnotations(); | |
| // Populate soft label slider displays | |
| restoreSoftLabelDisplays(); | |
| // Populate range slider displays | |
| restoreRangeSliderDisplays(); | |
| // Populate semantic differential radios | |
| restoreSemanticDifferentialAnnotations(); | |
| // Restore text edit schemas (populate editor textarea from saved data) | |
| restoreTextEditAnnotations(); | |
| // Restore extractive QA annotations | |
| restoreExtractiveQaAnnotations(); | |
| // Restore error span annotations | |
| restoreErrorSpanAnnotations(); | |
| // Restore card sort annotations | |
| restoreCardSortAnnotations(); | |
| // Restore trajectory eval annotations | |
| restoreTrajectoryEvalAnnotations(); | |
| // Restore trajectory edit (correction) annotations | |
| restoreTrajectoryEditAnnotations(); | |
| // Update character counters for text schemas with min_chars/show_char_count | |
| updateAllCharCounters(); | |
| validateRequiredFields(); | |
| } | |
| /** | |
| * Restore ranking annotations by reordering items to match saved order. | |
| */ | |
| function restoreRankingAnnotations() { | |
| const hiddenInputs = document.querySelectorAll('.ranking-order-input'); | |
| hiddenInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| const savedOrder = currentAnnotations[schema][labelName]; | |
| input.value = savedOrder; | |
| input.setAttribute('data-modified', 'true'); | |
| input.setAttribute('data-server-set', 'true'); | |
| // Reorder DOM items | |
| const list = input.closest('fieldset').querySelector('.ranking-list'); | |
| if (list) { | |
| const order = savedOrder.split(','); | |
| const items = Array.from(list.querySelectorAll('.ranking-item')); | |
| order.forEach((val, idx) => { | |
| const item = items.find(it => it.getAttribute('data-value') === val); | |
| if (item) { | |
| list.appendChild(item); | |
| item.querySelector('.ranking-rank').textContent = idx + 1; | |
| } | |
| }); | |
| } | |
| debugLog('Restored ranking annotation for', schema); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore hierarchical multiselect annotations by checking saved labels. | |
| */ | |
| function restoreHierarchicalAnnotations() { | |
| const hiddenInputs = document.querySelectorAll('.hier-selected-input'); | |
| hiddenInputs.forEach(input => { | |
| const schema = input.getAttribute('schema'); | |
| const labelName = input.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| const savedLabels = currentAnnotations[schema][labelName]; | |
| input.value = savedLabels; | |
| input.setAttribute('data-modified', 'true'); | |
| input.setAttribute('data-server-set', 'true'); | |
| // Check matching checkboxes | |
| const selected = savedLabels.split(',').map(s => s.trim()).filter(Boolean); | |
| const tree = input.closest('fieldset').querySelector('.hier-tree'); | |
| if (tree) { | |
| tree.querySelectorAll('.hier-checkbox').forEach(cb => { | |
| cb.checked = selected.includes(cb.value); | |
| }); | |
| // Update tags display | |
| const tagsContainer = tree.parentElement.querySelector('.hier-selected-tags'); | |
| if (tagsContainer) { | |
| tagsContainer.innerHTML = selected.map(s => | |
| '<span class="hier-tag">' + s + '</span>' | |
| ).join(''); | |
| } | |
| } | |
| debugLog('Restored hierarchical annotation for', schema); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore soft label slider display values and chart bars after populating range inputs. | |
| */ | |
| function restoreSoftLabelDisplays() { | |
| document.querySelectorAll('.shadcn-soft-label-container').forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| const total = parseInt(form.getAttribute('data-soft-label-total')) || 100; | |
| const sliders = form.querySelectorAll('.soft-label-slider'); | |
| sliders.forEach((s, idx) => { | |
| const valEl = document.getElementById('soft-label-val-' + s.id); | |
| if (valEl) valEl.textContent = s.value; | |
| const bar = document.getElementById('soft-label-bar-' + schema + '-' + idx); | |
| if (bar) bar.style.width = (parseInt(s.value) / total * 100) + '%'; | |
| }); | |
| // Update allocated/remaining indicators | |
| const sum = Array.from(sliders).reduce((acc, s) => acc + parseInt(s.value), 0); | |
| const allocEl = document.getElementById('soft-label-allocated-' + schema); | |
| if (allocEl) { | |
| allocEl.innerHTML = 'Allocated: <strong>' + sum + '</strong> / ' + total; | |
| } | |
| const remEl = document.getElementById('soft-label-remaining-' + schema); | |
| if (remEl) { | |
| remEl.innerHTML = 'Remaining: <strong>' + (total - sum) + '</strong>'; | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore range slider fill and value displays. | |
| */ | |
| function restoreRangeSliderDisplays() { | |
| document.querySelectorAll('.shadcn-range-slider-container').forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema) return; | |
| // Get saved values or read defaults from hidden inputs | |
| const lowInput = form.querySelector('[data-range-slider-role="low"]'); | |
| const highInput = form.querySelector('[data-range-slider-role="high"]'); | |
| if (!lowInput || !highInput) return; | |
| let lowVal, highVal; | |
| if (currentAnnotations[schema]) { | |
| lowVal = currentAnnotations[schema]['range_low']; | |
| highVal = currentAnnotations[schema]['range_high']; | |
| } | |
| // If we have saved values, restore them | |
| if (lowVal != null && highVal != null) { | |
| const renderFn = window['rangeSliderRender_' + schema]; | |
| if (renderFn) { | |
| renderFn(parseInt(lowVal), parseInt(highVal)); | |
| } | |
| } | |
| // Always mark hidden inputs as modified so they get synced | |
| lowInput.setAttribute('data-modified', 'true'); | |
| highInput.setAttribute('data-modified', 'true'); | |
| }); | |
| } | |
| /** | |
| * Restore semantic differential radio buttons using name-based matching. | |
| */ | |
| function restoreSemanticDifferentialAnnotations() { | |
| const forms = document.querySelectorAll('.shadcn-semantic-differential-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const radios = form.querySelectorAll('.semantic-differential-radio'); | |
| radios.forEach(radio => { | |
| const labelName = radio.getAttribute('label_name'); | |
| if (labelName && currentAnnotations[schema][labelName]) { | |
| if (radio.value === currentAnnotations[schema][labelName]) { | |
| radio.checked = true; | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Restore text edit annotations (populate editor textarea and trigger diff). | |
| */ | |
| function restoreTextEditAnnotations() { | |
| const forms = document.querySelectorAll('.shadcn-text-edit-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.text-edit-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const data = JSON.parse(currentAnnotations[schema][labelName]); | |
| if (data && data.edited_text !== undefined) { | |
| const editor = form.querySelector('.text-edit-textarea'); | |
| if (editor) { | |
| editor.value = data.edited_text; | |
| // Trigger diff update | |
| if (typeof window.textEditOnInput === 'function') { | |
| window.textEditOnInput(schema); | |
| } | |
| } | |
| } | |
| hiddenInput.value = currentAnnotations[schema][labelName]; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| } catch (e) { | |
| debugLog('Error restoring text edit annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore extractive QA annotations (highlight answer span). | |
| */ | |
| function restoreExtractiveQaAnnotations() { | |
| const forms = document.querySelectorAll('.shadcn-extractive-qa-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.eqa-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const data = JSON.parse(currentAnnotations[schema][labelName]); | |
| hiddenInput.value = currentAnnotations[schema][labelName]; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| if (data.unanswerable) { | |
| document.getElementById(schema + '-answer-text').textContent = 'Unanswerable'; | |
| var unansBtn = document.getElementById(schema + '-unanswerable'); | |
| if (unansBtn) unansBtn.classList.add('eqa-unanswerable-active'); | |
| } else if (data.answer_text) { | |
| document.getElementById(schema + '-answer-text').textContent = data.answer_text; | |
| // Re-highlight the span in the passage | |
| var container = document.getElementById(schema + '-passage'); | |
| if (container && data.start >= 0 && data.end > data.start) { | |
| var text = container.textContent; | |
| var color = container.dataset.highlightColor || '#FFEB3B'; | |
| container.innerHTML = text.substring(0, data.start) + | |
| '<span class="eqa-highlight" style="background-color:' + color + '">' + | |
| text.substring(data.start, data.end) + '</span>' + | |
| text.substring(data.end); | |
| } | |
| } | |
| } catch (e) { | |
| debugLog('Error restoring extractive QA annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore error span annotations. | |
| */ | |
| function restoreErrorSpanAnnotations() { | |
| const forms = document.querySelectorAll('.shadcn-error-span-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.error-span-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const data = JSON.parse(currentAnnotations[schema][labelName]); | |
| hiddenInput.value = currentAnnotations[schema][labelName]; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| if (data.errors && typeof window._errorSpanGetState === 'function') { | |
| var state = window._errorSpanGetState(schema); | |
| state.errors = data.errors; | |
| window._errorSpanUpdateDisplay(schema); | |
| } | |
| } catch (e) { | |
| debugLog('Error restoring error span annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore card sort annotations by placing cards into groups. | |
| */ | |
| function restoreCardSortAnnotations() { | |
| const forms = document.querySelectorAll('.shadcn-card-sort-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.card-sort-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const data = JSON.parse(currentAnnotations[schema][labelName]); | |
| hiddenInput.value = currentAnnotations[schema][labelName]; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| // Move cards into their saved groups | |
| if (data && typeof data === 'object') { | |
| Object.keys(data).forEach(function(groupName) { | |
| var items = data[groupName]; | |
| var groups = form.querySelectorAll('.card-sort-group'); | |
| groups.forEach(function(g) { | |
| if (g.dataset.group === groupName) { | |
| var container = g.querySelector('.card-sort-group-items'); | |
| items.forEach(function(text) { | |
| // Find card in source and move it | |
| var source = form.querySelector('.card-sort-source-items'); | |
| var cards = source ? source.querySelectorAll('.card-sort-card') : []; | |
| cards.forEach(function(c) { | |
| if (c.textContent.trim() === text) { | |
| container.appendChild(c); | |
| } | |
| }); | |
| }); | |
| } | |
| }); | |
| }); | |
| if (typeof window._cardSortUpdateCounts === 'function') { | |
| window._cardSortUpdateCounts(schema); | |
| } | |
| } | |
| } catch (e) { | |
| debugLog('Error restoring card sort annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore trajectory eval annotations from currentAnnotations. | |
| * Mirrors the error_span restore pattern: parse JSON, push into IIFE state, | |
| * then call the IIFE's visual-restore function. | |
| */ | |
| function restoreTrajectoryEvalAnnotations() { | |
| const forms = document.querySelectorAll('.trajectory-eval-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.trajectory-eval-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const data = JSON.parse(currentAnnotations[schema][labelName]); | |
| hiddenInput.value = currentAnnotations[schema][labelName]; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| if (data.steps && typeof window._trajGetState === 'function') { | |
| var state = window._trajGetState(); | |
| state.steps = data.steps; | |
| if (typeof window._trajBuildStepCards === 'function') { | |
| window._trajBuildStepCards(); | |
| } | |
| if (typeof window._trajRestoreVisualState === 'function') { | |
| window._trajRestoreVisualState(); | |
| } | |
| } | |
| } catch (e) { | |
| debugLog('Error restoring trajectory eval annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Restore trajectory edit (correction) annotations from currentAnnotations. | |
| * Populates the IIFE's per-schema state from the saved JSON, then rebuilds the | |
| * editors (prefilling textareas with edited_text) and re-runs the visual pass. | |
| */ | |
| function restoreTrajectoryEditAnnotations() { | |
| const forms = document.querySelectorAll('.trajectory-edit-container'); | |
| forms.forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| const hiddenInput = form.querySelector('.trajectory-edit-data-input'); | |
| if (!hiddenInput) return; | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (!labelName || !currentAnnotations[schema][labelName]) return; | |
| try { | |
| const raw = currentAnnotations[schema][labelName]; | |
| const data = JSON.parse(raw); | |
| hiddenInput.value = raw; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| if (window._trajEditState) { | |
| const st = window._trajEditState[schema] || { entries: {}, final_answer: null }; | |
| st.entries = {}; | |
| (data.steps || []).forEach(e => { | |
| st.entries[e.step_index + '::' + e.field] = e; | |
| }); | |
| st.final_answer = data.final_answer || null; | |
| window._trajEditState[schema] = st; | |
| } | |
| if (typeof window._trajEditBuild === 'function') window._trajEditBuild(); | |
| if (typeof window._trajEditRestore === 'function') window._trajEditRestore(); | |
| } catch (e) { | |
| debugLog('Error restoring trajectory edit annotation:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Update all character counters for text schemas with min_chars/show_char_count. | |
| */ | |
| function updateAllCharCounters() { | |
| const counters = document.querySelectorAll('.shadcn-textbox-char-counter'); | |
| counters.forEach(counter => { | |
| const inputId = counter.dataset.inputId; | |
| const minChars = parseInt(counter.dataset.minChars || '0', 10); | |
| const input = document.getElementById(inputId); | |
| if (!input) return; | |
| const len = (input.value || input.textContent || '').length; | |
| const countSpan = counter.querySelector('.shadcn-textbox-char-count'); | |
| if (countSpan) countSpan.textContent = len; | |
| if (minChars > 0) { | |
| counter.classList.toggle('char-count-met', len >= minChars); | |
| counter.classList.toggle('char-count-unmet', len < minChars); | |
| } | |
| // Set up live updating if not already done | |
| if (!input.dataset.charCounterBound) { | |
| input.dataset.charCounterBound = 'true'; | |
| input.addEventListener('input', function() { | |
| const l = (input.value || input.textContent || '').length; | |
| if (countSpan) countSpan.textContent = l; | |
| if (minChars > 0) { | |
| counter.classList.toggle('char-count-met', l >= minChars); | |
| counter.classList.toggle('char-count-unmet', l < minChars); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| // Span annotation functions | |
| function onlyOne(checkbox) { | |
| debugLog('🔍 [DEBUG] onlyOne() called with checkbox:', { | |
| id: checkbox.id, | |
| name: checkbox.name, | |
| value: checkbox.value, | |
| checked: checkbox.checked, | |
| className: checkbox.className | |
| }); | |
| var x = document.getElementsByClassName(checkbox.className); | |
| debugLog('🔍 [DEBUG] onlyOne() - Found elements with same class:', x.length); | |
| var i; | |
| for (i = 0; i < x.length; i++) { | |
| debugLog('🔍 [DEBUG] onlyOne() - Processing element:', { | |
| id: x[i].id, | |
| value: x[i].value, | |
| checked: x[i].checked, | |
| willUncheck: x[i].value != checkbox.value | |
| }); | |
| if (x[i].value != checkbox.value) { | |
| debugLog('🔍 [DEBUG] onlyOne() - Unchecking element:', x[i].id); | |
| x[i].checked = false; | |
| } | |
| } | |
| // Ensure the clicked checkbox is checked | |
| debugLog('🔍 [DEBUG] onlyOne() - Setting clicked checkbox to checked:', checkbox.id); | |
| checkbox.setAttribute('data-just-checked', 'true'); // Flag to prevent change event interference | |
| checkbox.checked = true; | |
| // Remove the flag after a short delay in case the change event doesn't fire | |
| setTimeout(() => { | |
| if (checkbox.hasAttribute('data-just-checked')) { | |
| debugLog('🔍 [DEBUG] onlyOne() - Removing data-just-checked flag after timeout'); | |
| checkbox.removeAttribute('data-just-checked'); | |
| } | |
| }, 100); | |
| } | |
| function extractSpanAnnotationsFromDOM() { | |
| /* | |
| * Extract span annotations from the DOM using the overlay system. | |
| * | |
| * Returns: | |
| * Array of span annotation objects with schema, name, start, end, title, value | |
| */ | |
| debugLog('[DEBUG] extractSpanAnnotationsFromDOM called'); | |
| const overlays = document.querySelectorAll('.span-overlay'); | |
| const spanAnnotations = []; | |
| for (const overlay of overlays) { | |
| const schema = overlay.getAttribute('data-schema'); | |
| const label = overlay.getAttribute('data-label'); | |
| const start = parseInt(overlay.getAttribute('data-start')); | |
| const end = parseInt(overlay.getAttribute('data-end')); | |
| const title = overlay.querySelector('.span-label')?.textContent?.trim() || label; | |
| // Get the text value by finding the covered segments | |
| const segments = document.querySelectorAll('.text-segment'); | |
| let coveredText = ''; | |
| for (const segment of segments) { | |
| const segStart = parseInt(segment.getAttribute('data-start')); | |
| const segEnd = parseInt(segment.getAttribute('data-end')); | |
| const spanIds = segment.getAttribute('data-span-ids')?.split(',') || []; | |
| // Check if this segment is covered by this overlay | |
| if (overlay.getAttribute('data-annotation-id') && | |
| spanIds.includes(overlay.getAttribute('data-annotation-id'))) { | |
| coveredText += segment.textContent; | |
| } | |
| } | |
| const targetField = overlay.getAttribute('data-target-field') || ''; | |
| // Extract span ID to preserve identity across saves | |
| const spanId = overlay.getAttribute('data-annotation-id') || | |
| overlay.getAttribute('data-span-id'); | |
| spanAnnotations.push({ | |
| schema: schema, | |
| name: label, | |
| start: start, | |
| end: end, | |
| title: title, | |
| value: coveredText, | |
| target_field: targetField, | |
| id: spanId | |
| }); | |
| } | |
| debugLog('[DEBUG] extractSpanAnnotationsFromDOM: found', spanAnnotations.length, 'spans:', spanAnnotations); | |
| return spanAnnotations; | |
| } | |
| function alignSpanOverlays() { | |
| /* | |
| * Align each .span-overlay to the union of its covered .text-segment spans. | |
| * This function positions overlays to match the actual text segments in the DOM. | |
| */ | |
| debugLog('[DEBUG] alignSpanOverlays called'); | |
| const overlays = document.querySelectorAll('.span-overlay'); | |
| const segments = Array.from(document.querySelectorAll('.text-segment')); | |
| const container = document.querySelector('.span-annotation-container'); | |
| if (!container) { | |
| console.warn('[DEBUG] alignSpanOverlays: No .span-annotation-container found'); | |
| return; | |
| } | |
| for (const overlay of overlays) { | |
| const annotationId = overlay.getAttribute('data-annotation-id'); | |
| if (!annotationId) { | |
| console.warn('[DEBUG] alignSpanOverlays: Overlay missing data-annotation-id'); | |
| continue; | |
| } | |
| // Find all segments covered by this overlay | |
| const coveredSegments = segments.filter(segment => { | |
| const spanIds = segment.getAttribute('data-span-ids')?.split(',') || []; | |
| return spanIds.includes(annotationId); | |
| }); | |
| if (coveredSegments.length === 0) { | |
| console.warn('[DEBUG] alignSpanOverlays: No segments found for overlay', annotationId); | |
| continue; | |
| } | |
| // Calculate the bounding rectangle of all covered segments | |
| let minLeft = Infinity; | |
| let maxRight = -Infinity; | |
| let minTop = Infinity; | |
| let maxBottom = -Infinity; | |
| for (const segment of coveredSegments) { | |
| const rect = segment.getBoundingClientRect(); | |
| const containerRect = container.getBoundingClientRect(); | |
| const relativeLeft = rect.left - containerRect.left; | |
| const relativeRight = rect.right - containerRect.left; | |
| const relativeTop = rect.top - containerRect.top; | |
| const relativeBottom = rect.bottom - containerRect.top; | |
| minLeft = Math.min(minLeft, relativeLeft); | |
| maxRight = Math.max(maxRight, relativeRight); | |
| minTop = Math.min(minTop, relativeTop); | |
| maxBottom = Math.max(maxBottom, relativeBottom); | |
| } | |
| // Position the overlay to cover all segments | |
| overlay.style.left = minLeft + 'px'; | |
| overlay.style.top = minTop + 'px'; | |
| overlay.style.width = (maxRight - minLeft) + 'px'; | |
| overlay.style.height = (maxBottom - minTop) + 'px'; | |
| overlay.style.backgroundColor = 'rgba(255, 230, 230, 0.3)'; | |
| overlay.style.border = '1px solid rgba(255, 230, 230, 0.8)'; | |
| debugLog('[DEBUG] alignSpanOverlays: Positioned overlay', annotationId, 'at', | |
| minLeft, minTop, maxRight - minLeft, maxBottom - minTop); | |
| } | |
| } | |
| // Robust selection mapping for overlay system | |
| function getSelectionIndicesOverlay() { | |
| /* | |
| * Get the start and end indices of the current text selection using the overlay approach. | |
| * | |
| * This function uses the unified text positioning approach to ensure | |
| * consistent offsets between frontend and backend. | |
| * | |
| * Returns: | |
| * Object with start and end indices in the original text | |
| */ | |
| debugLog('[DEBUG] getSelectionIndicesOverlay called'); | |
| var selection = window.getSelection(); | |
| if (!selection.rangeCount) { | |
| debugLog('[DEBUG] getSelectionIndicesOverlay: No selection range'); | |
| return { start: 0, end: 0 }; | |
| } | |
| var range = selection.getRangeAt(0); | |
| var container = document.getElementById('text-content'); | |
| if (!container) { | |
| debugLog('[DEBUG] getSelectionIndicesOverlay: No text-content container found'); | |
| return { start: 0, end: 0 }; | |
| } | |
| // Use the unified text positioning approach | |
| if (typeof calculateTextOffsetsFromSelection === 'function') { | |
| const offsets = calculateTextOffsetsFromSelection(container, range); | |
| debugLog('[DEBUG] getSelectionIndicesOverlay: Using unified approach, offsets:', offsets); | |
| return offsets; | |
| } | |
| // Fallback to the original approach if unified function is not available | |
| debugLog('[DEBUG] getSelectionIndicesOverlay: Using fallback approach'); | |
| return getOriginalTextOffsetsOverlay(container, range); | |
| } | |
| // Use overlay system for all span operations | |
| function changeSpanLabel(checkbox, schema, spanLabel, spanTitle, spanColor, targetField) { | |
| /* | |
| * Set up span annotation mode using the new span manager. | |
| * | |
| * Args: | |
| * checkbox: The checkbox element that was clicked | |
| * schema: The annotation schema | |
| * spanLabel: The span label | |
| * spanTitle: The span title | |
| * spanColor: The span color | |
| * targetField: The target field key for multi-span mode (optional) | |
| */ | |
| debugLog('[DEBUG] changeSpanLabel called:', { schema, spanLabel, spanTitle, spanColor, targetField, checked: checkbox.checked }); | |
| // Use the new span manager if available | |
| if (window.spanManager && window.spanManager.isInitialized) { | |
| debugLog('[DEBUG] changeSpanLabel: Using new span manager'); | |
| // Select the label, schema, and target field in the span manager | |
| window.spanManager.selectLabel(spanLabel, schema, targetField); | |
| // Set up text selection handler | |
| const textContainer = document.getElementById('instance-text'); | |
| if (textContainer) { | |
| // Create bound handlers once and store them for proper cleanup | |
| if (!boundEventHandlers.spanManagerMouseUp) { | |
| boundEventHandlers.spanManagerMouseUp = window.spanManager.handleTextSelection.bind(window.spanManager); | |
| boundEventHandlers.spanManagerKeyUp = window.spanManager.handleTextSelection.bind(window.spanManager); | |
| } | |
| // Remove existing handlers using stored references | |
| textContainer.removeEventListener('mouseup', boundEventHandlers.spanManagerMouseUp); | |
| textContainer.removeEventListener('keyup', boundEventHandlers.spanManagerKeyUp); | |
| // Add new handlers only when checkbox is checked | |
| if (checkbox.checked) { | |
| textContainer.addEventListener('mouseup', boundEventHandlers.spanManagerMouseUp); | |
| textContainer.addEventListener('keyup', boundEventHandlers.spanManagerKeyUp); | |
| debugLog('[DEBUG] changeSpanLabel: Text selection handlers added for span manager'); | |
| } | |
| } | |
| } else { | |
| // Defer to new manager once ready; avoid legacy overlay system to prevent conflicts | |
| debugLog('[DEBUG] changeSpanLabel: Span manager not ready; deferring selection to manager'); | |
| const waitAndSelect = () => { | |
| if (window.spanManager && window.spanManager.isInitialized) { | |
| window.spanManager.selectLabel(spanLabel, schema, targetField); | |
| return true; | |
| } | |
| return false; | |
| }; | |
| if (!waitAndSelect()) { | |
| let retries = 0; | |
| const timer = setInterval(() => { | |
| if (waitAndSelect() || ++retries > 20) clearInterval(timer); | |
| }, 100); | |
| } | |
| } | |
| // Add debugging to track checkbox state after function execution | |
| setTimeout(() => { | |
| debugLog('[DEBUG] changeSpanLabel: Checkbox state after execution:', { | |
| id: checkbox.id, | |
| checked: checkbox.checked, | |
| name: checkbox.name, | |
| value: checkbox.value | |
| }); | |
| }, 0); | |
| } | |
| function surroundSelection(schema, labelName, title, selectionColor) { | |
| // Only use overlay system | |
| surroundSelectionOverlay(schema, labelName, title, selectionColor); | |
| } | |
| function restoreSpanAnnotationsFromHTML() { | |
| // Only use overlay system | |
| restoreSpanAnnotationsFromHTMLOverlay(); | |
| } | |
| // LEGACY OVERLAY FUNCTIONS - DEPRECATED | |
| // These functions are kept for backward compatibility but are no longer used | |
| // The new boundary-based rendering system handles everything server-side | |
| // function getSelectionIndicesOverlay() { | |
| // /* | |
| // * Get selection indices for the overlay-based approach. | |
| // * | |
| // * This function works with the original text element and maps | |
| // * DOM selection to original text offsets. | |
| // * | |
| // * Returns: | |
| // * Object with start and end indices in the original text | |
| // */ | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay called'); | |
| // // Get the user selection | |
| // var selection = window.getSelection(); | |
| // if (selection.rangeCount === 0) { | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: No selection'); | |
| // return { start: -1, end: -1 }; // No selection | |
| // } | |
| // // Get the range object representing the selected portion | |
| // var range = selection.getRangeAt(0); | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: Selection details:', { | |
| // selectionText: selection.toString(), | |
| // selectionLength: selection.toString().length, | |
| // rangeStartContainer: range.startContainer, | |
| // rangeStartOffset: range.startOffset, | |
| // rangeEndContainer: range.endContainer, | |
| // rangeEndOffset: range.endOffset, | |
| // commonAncestor: range.commonAncestorContainer | |
| // }); | |
| // // Find the original text element within the span annotation container | |
| // var originalTextElement = $(range.commonAncestorContainer).closest('.original-text')[0]; | |
| // if (!originalTextElement) { | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: Not within .original-text'); | |
| // return { start: -2, end: -2 }; // Not within the original text | |
| // } | |
| // // Get the original text from the data attribute for comparison | |
| // var originalTextFromData = originalTextElement.getAttribute('data-original-text'); | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: Original text from data attribute:', originalTextFromData); | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: Original text length from data:', originalTextFromData ? originalTextFromData.length : 0); | |
| // // For the overlay approach, we can use a simpler offset calculation | |
| // // since the original text is unchanged and we can directly map DOM positions | |
| // var result = getOriginalTextOffsetsOverlay(originalTextElement, range); | |
| // debugLog('[DEBUG] getSelectionIndicesOverlay: Final result:', result); | |
| // return result; | |
| // } | |
| function getOriginalTextOffsetsOverlay(container, range) { | |
| /* | |
| * Get original text offsets for the overlay approach. | |
| * | |
| * This function uses the unified text positioning approach to ensure | |
| * consistent offsets between frontend and backend. | |
| * | |
| * Args: | |
| * container: The original text container element | |
| * range: The DOM range object | |
| * | |
| * Returns: | |
| * Object with start and end offsets in the original text | |
| */ | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay called'); | |
| // Use the unified text positioning approach | |
| if (typeof calculateTextOffsetsFromSelection === 'function') { | |
| const offsets = calculateTextOffsetsFromSelection(container, range); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: Using unified approach, offsets:', offsets); | |
| return offsets; | |
| } | |
| // Fallback to the original approach if unified function is not available | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: Using fallback approach'); | |
| // Get the original text from the data attribute (this is the clean text without HTML markup) | |
| var originalText = container.getAttribute('data-original-text'); | |
| if (!originalText) { | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: WARNING - no data-original-text attribute found, falling back to DOM text'); | |
| originalText = container.textContent || container.innerText; | |
| } | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: originalText from data attribute:', originalText); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: originalText length:', originalText.length); | |
| // Get the selected text | |
| var selectedText = window.getSelection().toString(); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: selectedText:', selectedText); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: selectedText length:', selectedText.length); | |
| // Find the selection in the original text | |
| var startIndex = originalText.indexOf(selectedText); | |
| var endIndex = startIndex + selectedText.length; | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: mapped indices:', { startIndex, endIndex }); | |
| // Verify the indices by extracting text | |
| if (startIndex !== -1) { | |
| var extractedText = originalText.substring(startIndex, endIndex); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: extracted text using indices:', extractedText); | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: extracted text matches selected text:', extractedText === selectedText); | |
| } else { | |
| debugLog('[DEBUG] getOriginalTextOffsetsOverlay: WARNING - selected text not found in original text!'); | |
| } | |
| return { start: startIndex, end: endIndex }; | |
| } | |
| // Update the existing surroundSelection function to work with overlays | |
| function surroundSelectionOverlay(schema, labelName, title, selectionColor) { | |
| /* | |
| * Create a span annotation using the overlay approach. | |
| * | |
| * Args: | |
| * schema: The annotation schema | |
| * labelName: The label name | |
| * title: The annotation title | |
| * selectionColor: The color for the annotation | |
| */ | |
| debugLog('[DEBUG] surroundSelectionOverlay called:', { schema, labelName, title, selectionColor }); | |
| // Check that this wasn't a spurious click or the click for the delete button which | |
| // also seems to trigger this selection event | |
| if (window.getSelection().rangeCount == 0) { | |
| debugLog('[DEBUG] surroundSelectionOverlay: No selection range found'); | |
| return; | |
| } | |
| var range = window.getSelection().getRangeAt(0); | |
| if (range.startOffset == range.endOffset) { | |
| debugLog('[DEBUG] surroundSelectionOverlay: Selection start and end offsets are the same'); | |
| return; | |
| } | |
| // Get the instance id | |
| var instance_id = document.getElementById("instance_id").value; | |
| debugLog('[DEBUG] surroundSelectionOverlay: Instance ID:', instance_id); | |
| if (window.getSelection) { | |
| var sel = window.getSelection(); | |
| // Check that we're labeling something in the original text that | |
| // we want to annotate | |
| if (!sel.anchorNode.parentElement) { | |
| debugLog('[DEBUG] surroundSelectionOverlay: No anchor node parent element'); | |
| return; | |
| } | |
| // Otherwise, we're going to be adding a new span annotation, if | |
| // the user has selected some non-empty part of the text | |
| if (sel.rangeCount && sel.toString().trim().length > 0) { | |
| debugLog('[DEBUG] surroundSelectionOverlay: Valid selection found, creating span'); | |
| // Get the selection text as a string | |
| var selText = window.getSelection().toString().trim(); | |
| debugLog('[DEBUG] surroundSelectionOverlay: Selected text:', selText); | |
| // Get the offsets for the server using the overlay approach | |
| var startEnd = getSelectionIndicesOverlay(); | |
| debugLog('[DEBUG] surroundSelectionOverlay: Selection indices:', startEnd); | |
| // Package this all up in a post request to the server's updateinstance endpoint | |
| var post_req = { | |
| type: "span", | |
| schema: schema, | |
| state: [ | |
| { | |
| name: labelName, | |
| start: startEnd["start"], | |
| end: startEnd["end"], | |
| title: title, | |
| value: selText | |
| } | |
| ], | |
| instance_id: instance_id | |
| }; | |
| debugLog('[DEBUG] surroundSelectionOverlay: Sending span annotation request:', post_req); | |
| // Send the request | |
| fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(post_req) | |
| }) | |
| .then(response => { | |
| if (response.ok) { | |
| debugLog('[DEBUG] surroundSelectionOverlay: Span annotation created successfully'); | |
| // Reload the page to show the new annotation | |
| location.reload(); | |
| } else { | |
| console.error('[DEBUG] surroundSelectionOverlay: Failed to create span annotation:', response.status); | |
| return response.json().then(error => { | |
| console.error('[DEBUG] surroundSelectionOverlay: Error details:', error); | |
| }); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('[DEBUG] surroundSelectionOverlay: Network error:', error); | |
| }); | |
| // Clear the current selection | |
| sel.empty(); | |
| debugLog('[DEBUG] surroundSelectionOverlay: Span creation request sent, page will reload'); | |
| } else { | |
| debugLog('[DEBUG] surroundSelectionOverlay: No valid selection found'); | |
| } | |
| } | |
| } | |
| // Update the existing changeSpanLabel function to use the overlay approach | |
| function changeSpanLabelOverlay(checkbox, schema, spanLabel, spanTitle, spanColor) { | |
| /* | |
| * Set up span annotation mode using the overlay approach. | |
| * | |
| * Args: | |
| * checkbox: The checkbox element that was clicked | |
| * schema: The annotation schema | |
| * spanLabel: The span label | |
| * spanTitle: The span title | |
| * spanColor: The span color | |
| */ | |
| debugLog('[DEBUG] changeSpanLabelOverlay called:', { schema, spanLabel, spanTitle, spanColor, checked: checkbox.checked }); | |
| // Listen for when the user has highlighted some text (only when the label is checked) | |
| document.onmouseup = function (e) { | |
| var senderElement = e.target; | |
| // Avoid the case where the user clicks the delete button | |
| if (senderElement.getAttribute("class") == "span-close") { | |
| e.stopPropagation(); | |
| return true; | |
| } | |
| if (checkbox.checked) { | |
| debugLog('[DEBUG] changeSpanLabelOverlay: Mouse up event - checkbox is checked, calling surroundSelectionOverlay'); | |
| surroundSelectionOverlay(schema, spanLabel, spanTitle, spanColor); | |
| } else { | |
| debugLog('[DEBUG] changeSpanLabelOverlay: Mouse up event - checkbox is not checked'); | |
| } | |
| }; | |
| } | |
| // Update the restoreSpanAnnotationsFromHTML function to work with overlays | |
| function restoreSpanAnnotationsFromHTMLOverlay() { | |
| /* | |
| * Extract span annotations from the overlay-based HTML structure. | |
| * | |
| * This function parses the overlay elements to reconstruct the span annotations. | |
| */ | |
| const container = document.querySelector('.span-annotation-container'); | |
| if (!container) return; | |
| const overlayElements = container.querySelectorAll('.span-overlay'); | |
| const found = []; | |
| overlayElements.forEach(overlay => { | |
| const schema = overlay.getAttribute('data-schema'); | |
| const name = overlay.getAttribute('data-label'); | |
| const start = parseInt(overlay.getAttribute('data-start')); | |
| const end = parseInt(overlay.getAttribute('data-end')); | |
| const annotationId = overlay.getAttribute('data-annotation-id'); | |
| found.push({ | |
| schema, | |
| name, | |
| title: name, // Use name as title for now | |
| start, | |
| end, | |
| id: annotationId, | |
| value: '' // Value is not stored in overlay, would need to extract from original text | |
| }); | |
| }); | |
| currentSpanAnnotations = found; | |
| debugLog('[DEBUG] restoreSpanAnnotationsFromHTMLOverlay: found', found.length, 'spans:', found); | |
| } | |
| // ============================================================================ | |
| // ROBUST SPAN ANNOTATION FUNCTIONS (Based on potato-span-fix approach) | |
| // ============================================================================ | |
| // Global variables for robust span annotation | |
| let spanColors = {}; | |
| let originalText = ''; | |
| let spanAnnotations = []; | |
| /** | |
| * Initialize robust span annotation system | |
| */ | |
| function initializeRobustSpanAnnotation() { | |
| debugLog('[ROBUST SPAN] Initializing robust span annotation system'); | |
| // Load span colors from config | |
| loadSpanColors(); | |
| // Set up text selection handlers | |
| setupRobustSpanSelection(); | |
| // Render existing spans | |
| renderSpansRobust(); | |
| } | |
| /** | |
| * Load span colors from the UI configuration | |
| */ | |
| async function loadSpanColors() { | |
| try { | |
| // Get colors from the current user state or config | |
| if (userState && userState.config && userState.config.ui && userState.config.ui.spans) { | |
| const configColors = userState.config.ui.spans.span_colors; | |
| // Flatten the color structure | |
| spanColors = {}; | |
| for (const schema in configColors) { | |
| for (const label in configColors[schema]) { | |
| spanColors[label] = configColors[schema][label]; | |
| } | |
| } | |
| } else { | |
| // Fallback colors | |
| spanColors = { | |
| 'happy': '(255, 230, 230)', | |
| 'sad': '(230, 243, 255)', | |
| 'angry': '(255, 230, 204)', | |
| 'surprised': '(230, 255, 230)', | |
| 'neutral': '(240, 240, 240)' | |
| }; | |
| } | |
| debugLog('[ROBUST SPAN] Loaded colors:', spanColors); | |
| } catch (error) { | |
| console.error('[ROBUST SPAN] Error loading colors:', error); | |
| } | |
| } | |
| /** | |
| * Set up text selection handlers for robust span annotation | |
| */ | |
| function setupRobustSpanSelection() { | |
| const textContainer = document.getElementById('instance-text'); | |
| if (!textContainer) { | |
| console.warn('[ROBUST SPAN] No text container found'); | |
| return; | |
| } | |
| // Remove existing handlers to avoid conflicts | |
| textContainer.removeEventListener('mouseup', handleRobustTextSelection); | |
| textContainer.removeEventListener('keyup', handleRobustTextSelection); | |
| // Add new handlers | |
| textContainer.addEventListener('mouseup', handleRobustTextSelection); | |
| textContainer.addEventListener('keyup', handleRobustTextSelection); | |
| debugLog('[ROBUST SPAN] Text selection handlers set up'); | |
| } | |
| /** | |
| * Handle text selection for robust span annotation | |
| */ | |
| function handleRobustTextSelection() { | |
| const selection = window.getSelection(); | |
| if (!selection.rangeCount || selection.isCollapsed) return; | |
| // Check if any span label is selected (returns object with label, schema, targetField) | |
| const activeSpanLabel = getActiveSpanLabel(); | |
| if (!activeSpanLabel) { | |
| debugLog('[ROBUST SPAN] No active span label selected'); | |
| return; | |
| } | |
| const range = selection.getRangeAt(0); | |
| const selectedText = selection.toString().trim(); | |
| if (!selectedText) return; | |
| // Detect target field from selection container if not set on the label | |
| if (!activeSpanLabel.targetField) { | |
| const container = range.startContainer.parentElement; | |
| const spanTargetEl = container ? container.closest('[id^="text-content-"]') : null; | |
| if (spanTargetEl) { | |
| const fieldKey = spanTargetEl.id.replace('text-content-', ''); | |
| if (fieldKey) activeSpanLabel.targetField = fieldKey; | |
| } | |
| } | |
| // Calculate positions using original text | |
| const start = getRobustTextPosition(selectedText, range); | |
| const end = start + selectedText.length; | |
| debugLog('[ROBUST SPAN] Creating span:', { | |
| text: selectedText, | |
| start: start, | |
| end: end, | |
| label: activeSpanLabel.label, | |
| schema: activeSpanLabel.schema, | |
| targetField: activeSpanLabel.targetField | |
| }); | |
| // Create the span annotation | |
| createRobustSpanAnnotation(selectedText, start, end, activeSpanLabel); | |
| // Clear selection | |
| selection.removeAllRanges(); | |
| } | |
| /** | |
| * Get the currently active span label from checkboxes | |
| */ | |
| function getActiveSpanLabel() { | |
| const spanCheckboxes = document.querySelectorAll('input[type="checkbox"][name*="span_label"]:checked'); | |
| if (spanCheckboxes.length === 0) return null; | |
| // Get the label from the first checked span checkbox | |
| const checkbox = spanCheckboxes[0]; | |
| // Name format is "span_label:::schemaName", extract schema | |
| const nameMatch = checkbox.name.match(/span_label:::(.+)/); | |
| if (!nameMatch) return null; | |
| const schema = nameMatch[1]; | |
| // Extract label from the checkbox ID (format: "schemaName_labelName") | |
| const idParts = checkbox.id.split('_'); | |
| const label = idParts.length >= 2 ? idParts.slice(1).join('_') : checkbox.value; | |
| const targetField = checkbox.getAttribute('data-target-field') || ''; | |
| return { label, schema, targetField }; | |
| } | |
| /** | |
| * Calculate text position robustly using original text | |
| */ | |
| function getRobustTextPosition(selectedText, range) { | |
| // Get the original text from the instance | |
| if (!currentInstance || !currentInstance.text) { | |
| console.warn('[ROBUST SPAN] No original text available'); | |
| return 0; | |
| } | |
| const originalText = currentInstance.text; | |
| // Find all occurrences of the selected text in the original text | |
| let indices = []; | |
| let idx = originalText.indexOf(selectedText); | |
| while (idx !== -1) { | |
| indices.push(idx); | |
| idx = originalText.indexOf(selectedText, idx + 1); | |
| } | |
| if (indices.length === 0) { | |
| console.warn('[ROBUST SPAN] Selected text not found in original text'); | |
| return 0; | |
| } | |
| if (indices.length === 1) { | |
| return indices[0]; | |
| } | |
| // If multiple occurrences, use the first one for now | |
| // In a more sophisticated implementation, we could use DOM position to disambiguate | |
| debugLog('[ROBUST SPAN] Multiple occurrences found, using first:', indices[0]); | |
| return indices[0]; | |
| } | |
| /** | |
| * Create a new span annotation using the robust approach | |
| */ | |
| async function createRobustSpanAnnotation(spanText, start, end, label) { | |
| try { | |
| // label can be a string (legacy) or object { label, schema, targetField } | |
| const labelName = typeof label === 'object' ? label.label : label; | |
| const schema = typeof label === 'object' ? label.schema : 'emotion'; | |
| const targetField = typeof label === 'object' ? (label.targetField || '') : ''; | |
| debugLog('[ROBUST SPAN] Creating annotation:', { spanText, start, end, label: labelName, schema, targetField }); | |
| // Use the existing /updateinstance endpoint | |
| const postData = { | |
| type: "span", | |
| schema: schema, | |
| state: [ | |
| { | |
| name: labelName, | |
| start: start, | |
| end: end, | |
| title: labelName, | |
| value: spanText, | |
| target_field: targetField | |
| } | |
| ], | |
| instance_id: currentInstance.id | |
| }; | |
| const response = await fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(postData) | |
| }); | |
| if (response.ok) { | |
| debugLog('[ROBUST SPAN] Span annotation created successfully'); | |
| // Reload the instance to show the new annotation | |
| await loadCurrentInstance(); | |
| } else { | |
| console.error('[ROBUST SPAN] Failed to create span annotation:', await response.text()); | |
| } | |
| } catch (error) { | |
| console.error('[ROBUST SPAN] Error creating span annotation:', error); | |
| } | |
| } | |
| /** | |
| * Render spans using the robust boundary-based algorithm | |
| */ | |
| function renderSpansRobust() { | |
| const textContainer = document.getElementById('instance-text'); | |
| if (!textContainer || !currentInstance) { | |
| console.warn('[ROBUST SPAN] Cannot render spans - missing container or instance'); | |
| return; | |
| } | |
| // Get the original text | |
| originalText = currentInstance.text || ''; | |
| if (!originalText) { | |
| console.warn('[ROBUST SPAN] No original text available'); | |
| return; | |
| } | |
| // Get span annotations from user state | |
| spanAnnotations = []; | |
| if (userState && userState.annotations && userState.annotations.by_instance) { | |
| const instanceAnnotations = userState.annotations.by_instance[currentInstance.id]; | |
| if (instanceAnnotations) { | |
| // Extract span annotations from the server format | |
| for (const [key, value] of Object.entries(instanceAnnotations)) { | |
| // Look for span annotations (this is a simplified approach) | |
| // In practice, we'd need to parse the actual span data structure | |
| if (typeof value === 'object' && value.start !== undefined && value.end !== undefined) { | |
| spanAnnotations.push({ | |
| id: key, | |
| span: value.value || '', | |
| label: value.name || key, | |
| start: value.start, | |
| end: value.end | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| debugLog('[ROBUST SPAN] Rendering spans:', spanAnnotations); | |
| if (spanAnnotations.length === 0) { | |
| // No spans - just show the original text | |
| textContainer.innerHTML = escapeHtml(originalText); | |
| return; | |
| } | |
| // Use the boundary-based algorithm from potato-span-fix | |
| const html = renderTextWithSpans(originalText, spanAnnotations); | |
| textContainer.innerHTML = html; | |
| } | |
| /** | |
| * Render text with spans using boundary-based algorithm | |
| */ | |
| function renderTextWithSpans(text, annotations) { | |
| // Create a list of all annotation boundaries (start and end points) | |
| const boundaries = []; | |
| annotations.forEach(annotation => { | |
| boundaries.push({ position: annotation.start, type: 'start', annotation }); | |
| boundaries.push({ position: annotation.end, type: 'end', annotation }); | |
| }); | |
| // Sort boundaries by position | |
| boundaries.sort((a, b) => a.position - b.position); | |
| // Build HTML by walking through the text and opening/closing spans | |
| let html = ''; | |
| let currentPos = 0; | |
| let openSpans = []; | |
| boundaries.forEach(boundary => { | |
| // Add text before this boundary | |
| if (boundary.position > currentPos) { | |
| html += escapeHtml(text.substring(currentPos, boundary.position)); | |
| } | |
| if (boundary.type === 'start') { | |
| // Open a new span | |
| const backgroundColor = getSpanColor(boundary.annotation.label); | |
| const span = `<span class="span-highlight" data-annotation-id="${boundary.annotation.id}" data-label="${boundary.annotation.label}" style="background-color: ${backgroundColor}"><span class="span-delete" onclick="deleteRobustSpan('${boundary.annotation.id}')">×</span><span class="span-label">${boundary.annotation.label}</span>`; | |
| html += span; | |
| openSpans.push(boundary.annotation); | |
| } else { | |
| // Close a span | |
| html += '</span>'; | |
| // Remove the closed span from openSpans | |
| const index = openSpans.findIndex(span => span.id === boundary.annotation.id); | |
| if (index !== -1) { | |
| openSpans.splice(index, 1); | |
| } | |
| } | |
| currentPos = boundary.position; | |
| }); | |
| // Add remaining text | |
| if (currentPos < text.length) { | |
| html += escapeHtml(text.substring(currentPos)); | |
| } | |
| // Close any remaining open spans | |
| openSpans.forEach(() => { | |
| html += '</span>'; | |
| }); | |
| return html; | |
| } | |
| /** | |
| * Get span color for a label | |
| */ | |
| function getSpanColor(label) { | |
| const color = spanColors[label]; | |
| if (!color) return '#f0f0f0'; // Default gray | |
| // Convert RGB format to hex | |
| const rgb = color.match(/\((\d+),\s*(\d+),\s*(\d+)\)/); | |
| if (rgb) { | |
| const r = parseInt(rgb[1]); | |
| const g = parseInt(rgb[2]); | |
| const b = parseInt(rgb[3]); | |
| return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; | |
| } | |
| return '#f0f0f0'; | |
| } | |
| /** | |
| * Delete a span annotation | |
| */ | |
| async function deleteRobustSpan(annotationId) { | |
| try { | |
| debugLog('[ROBUST SPAN] Deleting span:', annotationId); | |
| // Find the annotation to delete | |
| const annotation = spanAnnotations.find(a => a.id === annotationId); | |
| if (!annotation) { | |
| console.warn('[ROBUST SPAN] Annotation not found:', annotationId); | |
| return; | |
| } | |
| // Use the existing /updateinstance endpoint with value: null to delete | |
| const postData = { | |
| type: "span", | |
| schema: "emotion", // This should come from the config | |
| state: [ | |
| { | |
| name: annotation.label, | |
| start: annotation.start, | |
| end: annotation.end, | |
| title: annotation.label, | |
| value: null // This signals deletion | |
| } | |
| ], | |
| instance_id: currentInstance.id | |
| }; | |
| const response = await fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(postData) | |
| }); | |
| if (response.ok) { | |
| debugLog('[ROBUST SPAN] Span annotation deleted successfully'); | |
| // Reload the instance to show the updated state | |
| await loadCurrentInstance(); | |
| } else { | |
| console.error('[ROBUST SPAN] Failed to delete span annotation:', await response.text()); | |
| } | |
| } catch (error) { | |
| console.error('[ROBUST SPAN] Error deleting span annotation:', error); | |
| } | |
| } | |
| /** | |
| * Escape HTML content | |
| */ | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Initialize robust span annotation when the page loads | |
| document.addEventListener('DOMContentLoaded', function () { | |
| // Wait for the initial load to complete, then initialize robust spans | |
| // DISABLED: Legacy robust span system conflicts with new interval-based system | |
| // setTimeout(() => { | |
| // initializeRobustSpanAnnotation(); | |
| // }, 500); | |
| }); | |
| /** | |
| * Delete a span annotation - called from the HTML onclick | |
| */ | |
| async function deleteSpanAnnotation(annotationId, label, start, end) { | |
| try { | |
| debugLog('[SPAN DELETE] Deleting span:', { annotationId, label, start, end }); | |
| // Use the existing /updateinstance endpoint with value: null to delete | |
| const postData = { | |
| type: "span", | |
| schema: "emotion", // This should come from the config | |
| state: [ | |
| { | |
| name: label, | |
| start: start, | |
| end: end, | |
| title: label, | |
| value: null // This signals deletion | |
| } | |
| ], | |
| instance_id: currentInstance.id | |
| }; | |
| const response = await fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(postData) | |
| }); | |
| if (response.ok) { | |
| debugLog('[SPAN DELETE] Span annotation deleted successfully'); | |
| // Reload the instance to show the updated state | |
| await loadCurrentInstance(); | |
| } else { | |
| console.error('[SPAN DELETE] Failed to delete span annotation:', await response.text()); | |
| } | |
| } catch (error) { | |
| console.error('[SPAN DELETE] Error deleting span annotation:', error); | |
| } | |
| } | |
| // Add this function to help debug and clear erroneous span annotations | |
| async function debugAndClearSpans() { | |
| debugLog('🔍 [DEBUG] debugAndClearSpans() - ENTRY POINT'); | |
| if (!currentInstance || !currentInstance.id) { | |
| debugLog('🔍 [DEBUG] debugAndClearSpans() - No current instance'); | |
| return; | |
| } | |
| debugLog(`🔍 [DEBUG] debugAndClearSpans() - Current instance ID: ${currentInstance.id}`); | |
| try { | |
| // First, check what spans exist for this instance | |
| const response = await fetch(`/api/spans/${currentInstance.id}`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| debugLog(`🔍 [DEBUG] debugAndClearSpans() - Current spans for instance ${currentInstance.id}:`, data.spans); | |
| if (data.spans && data.spans.length > 0) { | |
| debugLog(`🔍 [DEBUG] debugAndClearSpans() - Found ${data.spans.length} spans, clearing them...`); | |
| // Clear the spans | |
| const clearResponse = await fetch(`/api/spans/${currentInstance.id}/clear`, { | |
| method: 'POST', | |
| credentials: 'include' | |
| }); | |
| if (clearResponse.ok) { | |
| const clearData = await clearResponse.json(); | |
| debugLog(`🔍 [DEBUG] debugAndClearSpans() - Cleared ${clearData.spans_cleared} spans`); | |
| // Reload the page to see the effect | |
| debugLog('🔍 [DEBUG] debugAndClearSpans() - Reloading page...'); | |
| window.location.reload(); | |
| } else { | |
| console.error('🔍 [DEBUG] debugAndClearSpans() - Failed to clear spans:', await clearResponse.text()); | |
| } | |
| } else { | |
| debugLog(`🔍 [DEBUG] debugAndClearSpans() - No spans found for instance ${currentInstance.id}`); | |
| } | |
| } else { | |
| console.error('🔍 [DEBUG] debugAndClearSpans() - Failed to get spans:', await response.text()); | |
| } | |
| } catch (error) { | |
| console.error('🔍 [DEBUG] debugAndClearSpans() - Error:', error); | |
| } | |
| } | |
| // Make the function available globally for debugging | |
| window.debugAndClearSpans = debugAndClearSpans; | |
| // Add this function to help debug instance_id values | |
| function debugInstanceId() { | |
| debugLog('🔍 [DEBUG] debugInstanceId() - ENTRY POINT'); | |
| // Check DOM instance_id | |
| const domInstanceId = document.getElementById('instance_id'); | |
| const domValue = domInstanceId ? domInstanceId.value : 'not found'; | |
| debugLog(`🔍 [DEBUG] debugInstanceId() - DOM instance_id value: '${domValue}'`); | |
| // Check currentInstance | |
| const currentInstanceId = currentInstance ? currentInstance.id : 'not set'; | |
| debugLog(`🔍 [DEBUG] debugInstanceId() - currentInstance.id: '${currentInstanceId}'`); | |
| // Check if they match | |
| if (domValue === currentInstanceId) { | |
| debugLog('🔍 [DEBUG] debugInstanceId() - ✅ DOM and currentInstance match'); | |
| } else { | |
| debugLog('🔍 [DEBUG] debugInstanceId() - ❌ DOM and currentInstance do NOT match'); | |
| } | |
| // Check what the API would return | |
| if (currentInstance && currentInstance.id) { | |
| debugLog(`🔍 [DEBUG] debugInstanceId() - API would be called with: /api/spans/${currentInstance.id}`); | |
| } | |
| } | |
| // Make the function available globally for debugging | |
| window.debugInstanceId = debugInstanceId; | |
| // Add this function to help debug and fix the instance_id issue in production | |
| function debugAndFixInstanceId() { | |
| debugLog('🔍 [DEBUG] debugAndFixInstanceId() - ENTRY POINT'); | |
| // Check current state | |
| const domInstanceId = document.getElementById('instance_id'); | |
| const domValue = domInstanceId ? domInstanceId.value : 'not found'; | |
| debugLog(`🔍 [DEBUG] debugAndFixInstanceId() - Current DOM instance_id: '${domValue}'`); | |
| // Check if we can force a hard refresh | |
| debugLog('🔍 [DEBUG] debugAndFixInstanceId() - Attempting to force hard refresh...'); | |
| // Clear any cached data | |
| if (window.caches) { | |
| caches.keys().then(names => { | |
| names.forEach(name => { | |
| debugLog(`🔍 [DEBUG] debugAndFixInstanceId() - Clearing cache: ${name}`); | |
| caches.delete(name); | |
| }); | |
| }); | |
| } | |
| // Force a hard refresh by adding a timestamp | |
| const currentUrl = window.location.href; | |
| const separator = currentUrl.includes('?') ? '&' : '?'; | |
| const newUrl = currentUrl + separator + '_t=' + Date.now(); | |
| debugLog(`🔍 [DEBUG] debugAndFixInstanceId() - Redirecting to: ${newUrl}`); | |
| // Redirect to force a fresh page load | |
| window.location.href = newUrl; | |
| } | |
| // Add this function to check if the page is cached | |
| function checkPageCache() { | |
| debugLog('🔍 [DEBUG] checkPageCache() - ENTRY POINT'); | |
| // Check if the page was loaded from cache | |
| if (window.performance && window.performance.navigation) { | |
| const navigationType = window.performance.navigation.type; | |
| debugLog(`🔍 [DEBUG] checkPageCache() - Navigation type: ${navigationType}`); | |
| if (navigationType === 1) { | |
| debugLog('🔍 [DEBUG] checkPageCache() - Page was reloaded'); | |
| } else if (navigationType === 2) { | |
| debugLog('🔍 [DEBUG] checkPageCache() - Page was loaded from back/forward cache'); | |
| } else { | |
| debugLog('🔍 [DEBUG] checkPageCache() - Page was loaded normally'); | |
| } | |
| } | |
| // Check if the page was loaded from cache using the newer API | |
| if (window.performance && window.performance.getEntriesByType) { | |
| const navigationEntries = window.performance.getEntriesByType('navigation'); | |
| if (navigationEntries.length > 0) { | |
| const entry = navigationEntries[0]; | |
| debugLog(`🔍 [DEBUG] checkPageCache() - Transfer size: ${entry.transferSize}`); | |
| debugLog(`🔍 [DEBUG] checkPageCache() - Encoded body size: ${entry.encodedBodySize}`); | |
| if (entry.transferSize === 0 && entry.encodedBodySize > 0) { | |
| debugLog('🔍 [DEBUG] checkPageCache() - Page was loaded from cache!'); | |
| } else { | |
| debugLog('🔍 [DEBUG] checkPageCache() - Page was loaded from network'); | |
| } | |
| } | |
| } | |
| } | |
| // Make the function available globally for debugging | |
| window.debugAndFixInstanceId = debugAndFixInstanceId; | |
| window.checkPageCache = checkPageCache; | |
| // Add this function to help clear erroneous span annotations and fix overlay persistence | |
| async function clearErroneousSpans() { | |
| debugLog('🔍 [DEBUG] clearErroneousSpans() - ENTRY POINT'); | |
| if (!currentInstance || !currentInstance.id) { | |
| debugLog('🔍 [DEBUG] clearErroneousSpans() - No current instance'); | |
| return; | |
| } | |
| debugLog(`🔍 [DEBUG] clearErroneousSpans() - Current instance ID: ${currentInstance.id}`); | |
| try { | |
| // Clear spans for the current instance | |
| const response = await fetch(`/api/spans/${currentInstance.id}/clear`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| } | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| debugLog(`🔍 [DEBUG] clearErroneousSpans() - Clear result:`, result); | |
| // Force reload the page to get fresh data | |
| debugLog('🔍 [DEBUG] clearErroneousSpans() - Reloading page to get fresh data'); | |
| window.location.reload(); | |
| } else { | |
| console.error(`🔍 [DEBUG] clearErroneousSpans() - Clear failed:`, response.status); | |
| } | |
| } catch (error) { | |
| console.error(`🔍 [DEBUG] clearErroneousSpans() - Error:`, error); | |
| } | |
| } | |
| // Make the function available globally for debugging | |
| window.clearErroneousSpans = clearErroneousSpans; | |
| // Add Firefox-specific instance_id fix that runs after page load | |
| function firefoxInstanceIdFix() { | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| if (!isFirefox) { | |
| return; // Only apply to Firefox | |
| } | |
| debugLog('🔍 [DEBUG] firefoxInstanceIdFix: Starting Firefox-specific instance_id fix'); | |
| // Wait a bit for the page to fully load | |
| setTimeout(() => { | |
| const instanceIdInput = document.getElementById('instance_id'); | |
| if (!instanceIdInput) { | |
| debugLog('🔍 [DEBUG] firefoxInstanceIdFix: No instance_id input found'); | |
| return; | |
| } | |
| // Get the current instance from the server-rendered data | |
| const currentInstanceId = currentInstance?.id; | |
| const domInstanceId = instanceIdInput.value; | |
| debugLog(`🔍 [DEBUG] firefoxInstanceIdFix: DOM instance_id: '${domInstanceId}', currentInstance.id: '${currentInstanceId}'`); | |
| if (currentInstanceId && domInstanceId !== currentInstanceId) { | |
| debugLog('🔍 [DEBUG] firefoxInstanceIdFix: Mismatch detected - fixing instance_id'); | |
| // Force update the input value | |
| instanceIdInput.value = currentInstanceId; | |
| // Force DOM update | |
| instanceIdInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| instanceIdInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| // Force reflow | |
| instanceIdInput.offsetHeight; | |
| debugLog(`🔍 [DEBUG] firefoxInstanceIdFix: Fixed instance_id to '${currentInstanceId}'`); | |
| } else { | |
| debugLog('🔍 [DEBUG] firefoxInstanceIdFix: No mismatch detected'); | |
| } | |
| }, 100); // Small delay to ensure page is loaded | |
| } | |
| // Call the Firefox fix after page load | |
| document.addEventListener('DOMContentLoaded', firefoxInstanceIdFix); | |
| window.addEventListener('load', firefoxInstanceIdFix); | |
| // Add function to test the Firefox instance_id fix | |
| function testFirefoxInstanceIdFix() { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: Testing Firefox instance_id fix'); | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| debugLog(`🔍 [DEBUG] testFirefoxInstanceIdFix: Is Firefox: ${isFirefox}`); | |
| const instanceIdInput = document.getElementById('instance_id'); | |
| if (!instanceIdInput) { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: No instance_id input found'); | |
| return; | |
| } | |
| const domInstanceId = instanceIdInput.value; | |
| const currentInstanceId = currentInstance?.id; | |
| debugLog(`🔍 [DEBUG] testFirefoxInstanceIdFix: DOM instance_id: '${domInstanceId}'`); | |
| debugLog(`🔍 [DEBUG] testFirefoxInstanceIdFix: currentInstance.id: '${currentInstanceId}'`); | |
| if (domInstanceId === currentInstanceId) { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: ✅ Instance IDs match'); | |
| } else { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: ❌ Instance IDs do not match'); | |
| // Try to fix it | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: Attempting to fix...'); | |
| firefoxInstanceIdFix(); | |
| // Check again after a short delay | |
| setTimeout(() => { | |
| const newDomInstanceId = instanceIdInput.value; | |
| debugLog(`🔍 [DEBUG] testFirefoxInstanceIdFix: After fix - DOM instance_id: '${newDomInstanceId}'`); | |
| if (newDomInstanceId === currentInstanceId) { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: ✅ Fix successful'); | |
| } else { | |
| debugLog('🔍 [DEBUG] testFirefoxInstanceIdFix: ❌ Fix failed'); | |
| } | |
| }, 200); | |
| } | |
| } | |
| // Make the function available globally for debugging | |
| window.testFirefoxInstanceIdFix = testFirefoxInstanceIdFix; | |
| // Add aggressive Firefox-specific instance_id fix | |
| function aggressiveFirefoxInstanceIdFix() { | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| if (!isFirefox) { | |
| return; // Only apply to Firefox | |
| } | |
| debugLog('🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Starting aggressive Firefox fix'); | |
| // Wait for the page to fully load | |
| setTimeout(() => { | |
| // Method 1: Force reload the instance_id input element | |
| const instanceIdInput = document.getElementById('instance_id'); | |
| if (!instanceIdInput) { | |
| debugLog('🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: No instance_id input found'); | |
| return; | |
| } | |
| // Get the current value from the DOM | |
| const currentDomValue = instanceIdInput.value; | |
| debugLog(`🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Current DOM value: '${currentDomValue}'`); | |
| // Method 2: Try to get the correct value from the server-rendered data | |
| // Look for any script tags or data attributes that might contain the correct instance_id | |
| let correctInstanceId = null; | |
| // Check if there's a script tag with instance data | |
| const scriptTags = document.querySelectorAll('script'); | |
| for (const script of scriptTags) { | |
| const content = script.textContent || script.innerHTML; | |
| if (content.includes('instance_id') || content.includes('currentInstance')) { | |
| debugLog('🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Found script with instance data'); | |
| // Try to extract instance_id from script content | |
| const match = content.match(/instance_id['"]?\s*[:=]\s*['"]([^'"]+)['"]/); | |
| if (match) { | |
| correctInstanceId = match[1]; | |
| debugLog(`🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Found instance_id in script: '${correctInstanceId}'`); | |
| break; | |
| } | |
| } | |
| } | |
| // Method 3: If we can't find it in scripts, try to infer from the URL or other page elements | |
| if (!correctInstanceId) { | |
| // Check if the URL contains an instance_id parameter | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const urlInstanceId = urlParams.get('instance_id'); | |
| if (urlInstanceId) { | |
| correctInstanceId = urlInstanceId; | |
| debugLog(`🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Found instance_id in URL: '${correctInstanceId}'`); | |
| } | |
| } | |
| // Method 4: If we still don't have it, try to get it from the server via API | |
| if (!correctInstanceId) { | |
| debugLog('🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: No instance_id found, trying API call'); | |
| // Make an API call to get the current instance info | |
| fetch('/api/current_instance', { | |
| method: 'GET', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| } | |
| }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data && data.instance_id) { | |
| correctInstanceId = data.instance_id; | |
| debugLog(`🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: Got instance_id from API: '${correctInstanceId}'`); | |
| applyInstanceIdFix(instanceIdInput, correctInstanceId); | |
| } | |
| }) | |
| .catch(error => { | |
| debugLog('🔍 [DEBUG] aggressiveFirefoxInstanceIdFix: API call failed:', error); | |
| }); | |
| } else { | |
| // Apply the fix immediately if we found the correct instance_id | |
| applyInstanceIdFix(instanceIdInput, correctInstanceId); | |
| } | |
| }, 200); // Longer delay to ensure page is fully loaded | |
| } | |
| // Helper function to apply the instance_id fix | |
| function applyInstanceIdFix(instanceIdInput, correctInstanceId) { | |
| const currentValue = instanceIdInput.value; | |
| if (currentValue !== correctInstanceId) { | |
| debugLog(`🔍 [DEBUG] applyInstanceIdFix: Fixing instance_id from '${currentValue}' to '${correctInstanceId}'`); | |
| // Force update the input value | |
| instanceIdInput.value = correctInstanceId; | |
| // Force DOM update with multiple methods | |
| instanceIdInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| instanceIdInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| instanceIdInput.dispatchEvent(new Event('blur', { bubbles: true })); | |
| // Force reflow | |
| instanceIdInput.offsetHeight; | |
| // Update currentInstance if it exists | |
| if (window.currentInstance) { | |
| window.currentInstance.id = correctInstanceId; | |
| debugLog(`🔍 [DEBUG] applyInstanceIdFix: Updated window.currentInstance.id to '${correctInstanceId}'`); | |
| } | |
| // Update currentInstance global variable if it exists | |
| if (typeof currentInstance !== 'undefined' && currentInstance) { | |
| currentInstance.id = correctInstanceId; | |
| debugLog(`🔍 [DEBUG] applyInstanceIdFix: Updated currentInstance.id to '${correctInstanceId}'`); | |
| } | |
| debugLog(`🔍 [DEBUG] applyInstanceIdFix: Fix applied successfully`); | |
| } else { | |
| debugLog(`🔍 [DEBUG] applyInstanceIdFix: No fix needed, instance_id is already correct: '${currentValue}'`); | |
| } | |
| } | |
| // Call the aggressive fix after page load | |
| document.addEventListener('DOMContentLoaded', aggressiveFirefoxInstanceIdFix); | |
| window.addEventListener('load', aggressiveFirefoxInstanceIdFix); | |
| // Also call it when the page becomes visible (in case of tab switching) | |
| document.addEventListener('visibilitychange', () => { | |
| if (!document.hidden) { | |
| setTimeout(aggressiveFirefoxInstanceIdFix, 100); | |
| } | |
| }); | |
| // Add function to test the aggressive Firefox fix | |
| function testAggressiveFirefoxFix() { | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: Testing aggressive Firefox fix'); | |
| const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); | |
| debugLog(`🔍 [DEBUG] testAggressiveFirefoxFix: Is Firefox: ${isFirefox}`); | |
| if (!isFirefox) { | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: Not Firefox, skipping test'); | |
| return; | |
| } | |
| // Call the aggressive fix | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: Calling aggressiveFirefoxInstanceIdFix'); | |
| aggressiveFirefoxInstanceIdFix(); | |
| // Check the result after a delay | |
| setTimeout(() => { | |
| const instanceIdInput = document.getElementById('instance_id'); | |
| if (!instanceIdInput) { | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: No instance_id input found'); | |
| return; | |
| } | |
| const finalInstanceId = instanceIdInput.value; | |
| const currentInstanceId = currentInstance?.id; | |
| debugLog(`🔍 [DEBUG] testAggressiveFirefoxFix: Final DOM instance_id: '${finalInstanceId}'`); | |
| debugLog(`🔍 [DEBUG] testAggressiveFirefoxFix: currentInstance.id: '${currentInstanceId}'`); | |
| if (finalInstanceId === currentInstanceId) { | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: ✅ Fix successful - instance IDs match'); | |
| } else { | |
| debugLog('🔍 [DEBUG] testAggressiveFirefoxFix: ❌ Fix failed - instance IDs do not match'); | |
| } | |
| }, 500); | |
| } | |
| /** | |
| * Jump to the previous unannotated instance. | |
| * Saves current annotations and navigates to the first unannotated item before the current position. | |
| * If all items are annotated, shows a notification. | |
| */ | |
| async function jumpToUnannotatedPrev() { | |
| debugLog('[NAV] jumpToUnannotatedPrev - ENTRY POINT'); | |
| if (isLoading) { | |
| debugLog('[NAV] jumpToUnannotatedPrev - Navigation blocked, still loading'); | |
| return; | |
| } | |
| setLoading(true); | |
| debugLog('[NAV] jumpToUnannotatedPrev - Loading set to true'); | |
| // Track navigation event | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackNavigation('jump_to_unannotated_prev', currentInstance?.id, null); | |
| } | |
| try { | |
| // Save annotations before navigating away | |
| debugLog('[NAV] jumpToUnannotatedPrev - Saving annotations before navigation'); | |
| await saveAnnotations(); | |
| // Use the correct endpoint and payload for navigation | |
| const response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| action: 'jump_to_unannotated_prev', | |
| instance_id: currentInstance?.id | |
| }) | |
| }); | |
| if (response.ok) { | |
| // Check if the response indicates no unannotated items | |
| const contentType = response.headers.get('content-type'); | |
| if (contentType && contentType.includes('application/json')) { | |
| const result = await response.json(); | |
| if (result.status === 'no_unannotated') { | |
| debugLog('[NAV] jumpToUnannotatedPrev - All items annotated'); | |
| showNotification('All items have been annotated!', 'info'); | |
| setLoading(false); | |
| return; | |
| } | |
| } | |
| debugLog('[NAV] jumpToUnannotatedPrev - Navigation successful, reloading page'); | |
| window.location.reload(); | |
| } else { | |
| console.error('[NAV] jumpToUnannotatedPrev - Navigation failed:', response.status); | |
| setLoading(false); | |
| } | |
| } catch (error) { | |
| console.error('[NAV] jumpToUnannotatedPrev - Navigation error:', error); | |
| setLoading(false); | |
| } | |
| } | |
| /** | |
| * Jump to the next unannotated instance. | |
| * Saves current annotations and navigates to the first unannotated item after the current position. | |
| * If all items are annotated, shows a notification. | |
| */ | |
| async function jumpToUnannotated() { | |
| debugLog('[NAV] jumpToUnannotated - ENTRY POINT'); | |
| if (isLoading) { | |
| debugLog('[NAV] jumpToUnannotated - Navigation blocked, still loading'); | |
| return; | |
| } | |
| // Client-side required field validation | |
| hasAttemptedForwardValidation = true; | |
| if (!validateRequiredFields({ showErrors: true })) { | |
| debugLog('[NAV] jumpToUnannotated - blocked by client-side validation'); | |
| return; | |
| } | |
| setLoading(true); | |
| debugLog('[NAV] jumpToUnannotated - Loading set to true'); | |
| // Track navigation event | |
| if (window.interactionTracker) { | |
| window.interactionTracker.trackNavigation('jump_to_unannotated', currentInstance?.id, null); | |
| } | |
| try { | |
| // Save annotations before navigating away | |
| debugLog('[NAV] jumpToUnannotated - Saving annotations before navigation'); | |
| await saveAnnotations(); | |
| // Use the correct endpoint and payload for navigation | |
| const response = await fetch('/annotate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| action: 'jump_to_unannotated', | |
| instance_id: currentInstance?.id | |
| }) | |
| }); | |
| if (response.ok) { | |
| // Check if the response indicates no unannotated items | |
| const contentType = response.headers.get('content-type'); | |
| if (contentType && contentType.includes('application/json')) { | |
| const result = await response.json(); | |
| if (result.status === 'no_unannotated') { | |
| debugLog('[NAV] jumpToUnannotated - All items annotated'); | |
| showNotification('All items have been annotated!', 'info'); | |
| setLoading(false); | |
| return; | |
| } | |
| } | |
| debugLog('[NAV] jumpToUnannotated - Navigation successful, reloading page'); | |
| window.location.reload(); | |
| } else { | |
| await handleNavigationResponseError(response); | |
| setLoading(false); | |
| } | |
| } catch (error) { | |
| console.error('[NAV] jumpToUnannotated - Navigation error:', error); | |
| setLoading(false); | |
| } | |
| } | |
| function handleQualityControlResponse(result) { | |
| if (!result || typeof result !== 'object') { | |
| return; | |
| } | |
| const qcResult = result.qc_result && typeof result.qc_result === 'object' | |
| ? result.qc_result | |
| : null; | |
| const message = result.warning_message || result.message || (qcResult && qcResult.message); | |
| const isBlocked = result.status === 'blocked' || (qcResult && qcResult.blocked); | |
| if (isBlocked) { | |
| showNotification(message || 'You have been blocked.', 'error'); | |
| showError(true, message || 'You have been blocked due to quality control checks. Your session has ended.', { permanent: true }); | |
| return; | |
| } | |
| const isWarning = (result.warning || (qcResult && qcResult.warning)) && | |
| !(qcResult && qcResult.passed === true); | |
| if (isWarning) { | |
| showNotification(message || 'Please read items carefully before answering.', 'warning'); | |
| } | |
| } | |
| /** | |
| * Show a notification message to the user. | |
| * @param {string} message - The message to display | |
| * @param {string} type - The type of notification ('info', 'success', 'warning', 'error') | |
| */ | |
| function showNotification(message, type = 'info') { | |
| // Check if a notification container exists, create if not | |
| let container = document.getElementById('notification-container'); | |
| if (!container) { | |
| container = document.createElement('div'); | |
| container.id = 'notification-container'; | |
| container.style.cssText = 'position: fixed; top: 80px; right: 20px; z-index: 9999;'; | |
| document.body.appendChild(container); | |
| } | |
| // Create the notification element | |
| const notification = document.createElement('div'); | |
| notification.className = `notification notification-${type}`; | |
| notification.style.cssText = ` | |
| padding: 12px 20px; | |
| margin-bottom: 10px; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| animation: slideIn 0.3s ease; | |
| background-color: ${type === 'info' ? '#e0f2fe' : type === 'success' ? '#dcfce7' : type === 'warning' ? '#fef3c7' : '#fee2e2'}; | |
| color: ${type === 'info' ? '#0369a1' : type === 'success' ? '#166534' : type === 'warning' ? '#92400e' : '#dc2626'}; | |
| border: 1px solid ${type === 'info' ? '#7dd3fc' : type === 'success' ? '#86efac' : type === 'warning' ? '#fcd34d' : '#fca5a5'}; | |
| `; | |
| notification.textContent = message; | |
| container.appendChild(notification); | |
| // Auto-remove after 4 seconds | |
| setTimeout(() => { | |
| notification.style.animation = 'slideOut 0.3s ease'; | |
| setTimeout(() => notification.remove(), 300); | |
| }, 4000); | |
| } | |
| // Make the function available globally for debugging | |
| window.testAggressiveFirefoxFix = testAggressiveFirefoxFix; | |
| window.navigateToNext = navigateToNext; | |
| window.navigateToPrevious = navigateToPrevious; | |
| window.jumpToUnannotated = jumpToUnannotated; | |
| window.jumpToUnannotatedPrev = jumpToUnannotatedPrev; | |
| window.showNotification = showNotification; | |
| window.handleQualityControlResponse = handleQualityControlResponse; | |
| window.loadCurrentInstance = loadCurrentInstance; | |
| // ======================================== | |
| // PAIRWISE ANNOTATION HANDLERS | |
| // ======================================== | |
| /** | |
| * Initialize pairwise annotation interface. | |
| * Called after DOMContentLoaded and after forms are generated. | |
| */ | |
| function initPairwiseAnnotation() { | |
| debugLog('[PAIRWISE] Initializing pairwise annotation'); | |
| // Setup tile click handlers for binary mode | |
| document.querySelectorAll('.pairwise-tile').forEach(tile => { | |
| tile.addEventListener('click', function() { | |
| selectPairwiseTile(this); | |
| }); | |
| // Keyboard support (Enter/Space) | |
| tile.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| selectPairwiseTile(this); | |
| } | |
| }); | |
| }); | |
| // Setup tie/neither button handlers | |
| document.querySelectorAll('.pairwise-tie-btn, .pairwise-neither-btn').forEach(btn => { | |
| btn.addEventListener('click', function() { | |
| selectPairwiseOption(this); | |
| }); | |
| }); | |
| // Setup justification checkbox handlers | |
| document.querySelectorAll('.pairwise-reason-cb').forEach(cb => { | |
| cb.addEventListener('change', function() { | |
| const schema = this.getAttribute('data-schema'); | |
| savePairwiseJustification(schema); | |
| }); | |
| }); | |
| // Populate pairwise tile content from instance data | |
| populatePairwiseTileContent(); | |
| debugLog('[PAIRWISE] Initialization complete'); | |
| } | |
| /** | |
| * Populate pairwise tile content from the current instance data. | |
| */ | |
| function populatePairwiseTileContent() { | |
| const pairwiseForms = document.querySelectorAll('.annotation-form.pairwise'); | |
| if (pairwiseForms.length === 0) { | |
| return; | |
| } | |
| // Get items from instance text | |
| let items = null; | |
| const instanceText = document.getElementById('instance-text'); | |
| if (instanceText) { | |
| const textContent = instanceText.querySelector('#text-content'); | |
| const contentElement = textContent || instanceText; | |
| // Method 1: Check for data-item-index attributes | |
| const listItems = contentElement.querySelectorAll('[data-item-index]'); | |
| if (listItems.length >= 2) { | |
| items = Array.from(listItems).map(el => el.textContent.trim()); | |
| } | |
| // Method 2: Parse HTML with <b>A.</b> / <b>B.</b> markers (list_as_text format) | |
| if (!items) { | |
| const html = contentElement.innerHTML; | |
| const parts = html.split(/<br\s*\/?>\s*<br\s*\/?>/i); | |
| if (parts.length >= 2) { | |
| items = parts.map(part => { | |
| const temp = document.createElement('div'); | |
| temp.innerHTML = part; | |
| let text = temp.textContent || ''; | |
| text = text.replace(/^[A-Z]\.\s*/, '').trim(); | |
| return text; | |
| }).filter(t => t.length > 0); | |
| if (items.length < 2) items = null; | |
| } | |
| } | |
| // Method 3: Parse text content directly with regex | |
| if (!items) { | |
| const rawText = contentElement.textContent || ''; | |
| const regex = /([A-Z])\.\s*([\s\S]*?)(?=(?:[A-Z]\.\s)|$)/g; | |
| const matches = []; | |
| let match; | |
| while ((match = regex.exec(rawText)) !== null) { | |
| const text = match[2].trim(); | |
| if (text.length > 0) matches.push(text); | |
| } | |
| if (matches.length >= 2) items = matches; | |
| } | |
| } | |
| // Method 4: Try window.currentInstanceData | |
| if (!items) { | |
| const firstForm = pairwiseForms[0]; | |
| const itemsKey = firstForm.getAttribute('data-items-key') || 'text'; | |
| if (window.currentInstanceData && window.currentInstanceData[itemsKey]) { | |
| const data = window.currentInstanceData[itemsKey]; | |
| if (Array.isArray(data) && data.length >= 2) items = data; | |
| } | |
| } | |
| if (items && items.length >= 2) { | |
| // Create pairwise items display ONCE at top (if not exists) | |
| createPairwiseItemsDisplay(items, pairwiseForms[0]); | |
| // Wrap pairwise forms in a flex container for side-by-side layout | |
| wrapPairwiseFormsInFlexContainer(pairwiseForms); | |
| // Hide the "Text to Annotate" section | |
| const instanceTextContainer = document.querySelector('.instance-text-container'); | |
| if (instanceTextContainer) { | |
| instanceTextContainer.style.display = 'none'; | |
| } | |
| // Also hide the "Text to Annotate" heading | |
| const textHeading = document.querySelector('h5.mb-3'); | |
| if (textHeading && textHeading.textContent.includes('Text to Annotate')) { | |
| textHeading.style.display = 'none'; | |
| } | |
| } | |
| } | |
| /** | |
| * Wrap ALL annotation forms in a grid container for layout control. | |
| * Forms can specify column span via data-grid-columns attribute. | |
| * This enables side-by-side layout for multiple annotation schemas. | |
| * | |
| * Note: If FormLayoutManager is initialized, it handles wrapping. | |
| * This function provides fallback behavior for backwards compatibility. | |
| */ | |
| function wrapPairwiseFormsInFlexContainer(pairwiseForms) { | |
| // Check if FormLayoutManager already handled this | |
| if (window.formLayoutManager && window.formLayoutManager.initialized) { | |
| debugLog('[wrapPairwiseFormsInFlexContainer] Skipping - FormLayoutManager active'); | |
| return; | |
| } | |
| // Check if wrapper already exists | |
| if (document.querySelector('.annotation-forms-layout') || | |
| document.querySelector('.annotation-forms-grid')) { | |
| return; | |
| } | |
| // Get ALL annotation forms in the container | |
| const annotationFormsContainer = document.getElementById('annotation-forms'); | |
| if (!annotationFormsContainer) return; | |
| const allForms = annotationFormsContainer.querySelectorAll('.annotation-form'); | |
| if (allForms.length < 2) { | |
| return; // No need to wrap if only one form | |
| } | |
| // Create a wrapper div with grid layout | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'pairwise-forms-wrapper annotation-forms-grid'; | |
| // Insert wrapper at the end of the pairwise items display (if exists) or at start | |
| const pairwiseDisplay = annotationFormsContainer.querySelector('.pairwise-items-display-container'); | |
| if (pairwiseDisplay) { | |
| pairwiseDisplay.after(wrapper); | |
| } else { | |
| annotationFormsContainer.insertBefore(wrapper, annotationFormsContainer.firstChild); | |
| } | |
| // Move ALL annotation forms into the wrapper | |
| allForms.forEach(form => { | |
| // Set default grid column if not specified | |
| if (!form.hasAttribute('data-grid-columns')) { | |
| form.setAttribute('data-grid-columns', '1'); | |
| } | |
| wrapper.appendChild(form); | |
| }); | |
| } | |
| /** | |
| * Create a single pairwise items display at the top of the annotation forms. | |
| */ | |
| function createPairwiseItemsDisplay(items, referenceForm) { | |
| // Check if display already exists | |
| if (document.querySelector('.pairwise-items-display-container')) { | |
| // Just update the content | |
| const boxes = document.querySelectorAll('.pairwise-items-display-container .pairwise-item-box'); | |
| boxes.forEach((box, index) => { | |
| if (index < items.length) box.textContent = items[index]; | |
| }); | |
| return; | |
| } | |
| // Get labels from the first pairwise form | |
| const labels = ['Response A', 'Response B']; | |
| // Create the display container | |
| const displayContainer = document.createElement('div'); | |
| displayContainer.className = 'pairwise-items-display-container'; | |
| displayContainer.innerHTML = ` | |
| <div class="pairwise-items-display"> | |
| <div class="pairwise-item-wrapper"> | |
| <div class="pairwise-item-title">${labels[0]}</div> | |
| <div class="pairwise-item-box">${escapeHtml(items[0])}</div> | |
| </div> | |
| <div class="pairwise-item-wrapper"> | |
| <div class="pairwise-item-title">${labels[1]}</div> | |
| <div class="pairwise-item-box">${escapeHtml(items[1])}</div> | |
| </div> | |
| </div> | |
| `; | |
| // Insert before the first pairwise form | |
| const annotationForms = document.getElementById('annotation-forms'); | |
| if (annotationForms) { | |
| annotationForms.insertBefore(displayContainer, annotationForms.firstChild); | |
| } | |
| } | |
| /** | |
| * Simple HTML escape function. | |
| */ | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| /** | |
| * Select a pairwise tile (binary mode). | |
| * @param {HTMLElement} tile - The tile element clicked | |
| */ | |
| function selectPairwiseTile(tile) { | |
| const schema = tile.getAttribute('data-schema'); | |
| const value = tile.getAttribute('data-value'); | |
| const dimension = tile.getAttribute('data-dimension'); | |
| const form = tile.closest('form'); | |
| if (!form) return; | |
| debugLog(`[PAIRWISE] Selecting tile: schema=${schema}, value=${value}, dim=${dimension || 'none'}`); | |
| // For multi-dimension mode, scope to the dimension row | |
| const scope = dimension ? tile.closest('.pairwise-dimension-row') : form; | |
| // Deselect all tiles and buttons in scope | |
| scope.querySelectorAll('.pairwise-tile').forEach(t => t.classList.remove('selected')); | |
| scope.querySelectorAll('.pairwise-tie-btn, .pairwise-neither-btn').forEach(b => b.classList.remove('selected')); | |
| // Select this tile | |
| tile.classList.add('selected'); | |
| // Update hidden input — in multi-dim mode find the dimension-specific input | |
| let hiddenInput; | |
| if (dimension) { | |
| hiddenInput = scope.querySelector(`.pairwise-dim-input[data-dimension="${dimension}"]`); | |
| } else { | |
| hiddenInput = form.querySelector('.pairwise-value'); | |
| } | |
| if (hiddenInput) { | |
| hiddenInput.value = value; | |
| // Trigger annotation save | |
| registerAnnotation(hiddenInput); | |
| // Trigger change event for validation | |
| hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| // Update validation | |
| validateRequiredFields(); | |
| } | |
| /** | |
| * Select a pairwise option button (tie/neither). | |
| * @param {HTMLElement} btn - The button element clicked | |
| */ | |
| function selectPairwiseOption(btn) { | |
| const schema = btn.getAttribute('data-schema'); | |
| const value = btn.getAttribute('data-value'); | |
| const dimension = btn.getAttribute('data-dimension'); | |
| const form = btn.closest('form'); | |
| if (!form) return; | |
| debugLog(`[PAIRWISE] Selecting option: schema=${schema}, value=${value}, dim=${dimension || 'none'}`); | |
| // For multi-dimension mode, scope to the dimension row | |
| const scope = dimension ? btn.closest('.pairwise-dimension-row') : form; | |
| // Deselect all tiles and buttons in scope | |
| scope.querySelectorAll('.pairwise-tile').forEach(t => t.classList.remove('selected')); | |
| scope.querySelectorAll('.pairwise-tie-btn, .pairwise-neither-btn').forEach(b => b.classList.remove('selected')); | |
| // Select this button | |
| btn.classList.add('selected'); | |
| // Update hidden input | |
| let hiddenInput; | |
| if (dimension) { | |
| hiddenInput = scope.querySelector(`.pairwise-dim-input[data-dimension="${dimension}"]`); | |
| } else { | |
| hiddenInput = form.querySelector('.pairwise-value'); | |
| } | |
| if (hiddenInput) { | |
| hiddenInput.value = value; | |
| // Trigger annotation save | |
| registerAnnotation(hiddenInput); | |
| // Trigger change event for validation | |
| hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| // Update validation | |
| validateRequiredFields(); | |
| } | |
| /** | |
| * Update the pairwise scale display when slider value changes. | |
| * @param {HTMLElement} slider - The slider input element | |
| */ | |
| function updatePairwiseScaleDisplay(slider) { | |
| const form = slider.closest('form'); | |
| if (!form) return; | |
| const valueDisplay = form.querySelector('.pairwise-scale-current-value'); | |
| if (valueDisplay) { | |
| valueDisplay.textContent = slider.value; | |
| } | |
| } | |
| /** | |
| * Restore pairwise annotation state from saved annotations. | |
| * Called during populateInputValues. | |
| */ | |
| function restorePairwiseAnnotations() { | |
| if (!currentAnnotations) return; | |
| debugLog('[PAIRWISE] Restoring pairwise annotations'); | |
| // Restore binary mode selections | |
| document.querySelectorAll('.annotation-form.pairwise-binary').forEach(form => { | |
| const hiddenInput = form.querySelector('.pairwise-value'); | |
| if (!hiddenInput) return; | |
| const schema = hiddenInput.getAttribute('schema'); | |
| const labelName = hiddenInput.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| const savedValue = currentAnnotations[schema][labelName]; | |
| hiddenInput.value = savedValue; | |
| // Select the appropriate tile or button | |
| if (savedValue === 'tie' || savedValue === 'neither') { | |
| const optionBtn = form.querySelector(`.pairwise-tie-btn[data-value="${savedValue}"], .pairwise-neither-btn[data-value="${savedValue}"]`); | |
| if (optionBtn) { | |
| optionBtn.classList.add('selected'); | |
| } | |
| } else { | |
| const tile = form.querySelector(`.pairwise-tile[data-value="${savedValue}"]`); | |
| if (tile) { | |
| tile.classList.add('selected'); | |
| } | |
| } | |
| debugLog(`[PAIRWISE] Restored binary selection: ${schema}/${labelName} = ${savedValue}`); | |
| } | |
| }); | |
| // Restore scale mode values | |
| document.querySelectorAll('.annotation-form.pairwise-scale').forEach(form => { | |
| const slider = form.querySelector('.pairwise-scale-slider'); | |
| if (!slider) return; | |
| const schema = slider.getAttribute('schema'); | |
| const labelName = slider.getAttribute('label_name'); | |
| if (schema && labelName && currentAnnotations[schema] && currentAnnotations[schema][labelName]) { | |
| const savedValue = currentAnnotations[schema][labelName]; | |
| slider.value = savedValue; | |
| updatePairwiseScaleDisplay(slider); | |
| debugLog(`[PAIRWISE] Restored scale value: ${schema}/${labelName} = ${savedValue}`); | |
| } | |
| }); | |
| // Restore multi-dimension mode | |
| document.querySelectorAll('.annotation-form.pairwise-multi-dimension').forEach(form => { | |
| const schema = form.getAttribute('data-schema-name'); | |
| if (!schema || !currentAnnotations[schema]) return; | |
| form.querySelectorAll('.pairwise-dim-input').forEach(input => { | |
| const dim = input.getAttribute('data-dimension'); | |
| if (!dim || !currentAnnotations[schema][dim]) return; | |
| const val = currentAnnotations[schema][dim]; | |
| input.value = val; | |
| const row = input.closest('.pairwise-dimension-row'); | |
| if (!row) return; | |
| row.querySelectorAll('.pairwise-tile, .pairwise-tie-btn').forEach(t => t.classList.remove('selected')); | |
| if (val === 'tie') { | |
| const btn = row.querySelector('.pairwise-tie-btn'); | |
| if (btn) btn.classList.add('selected'); | |
| } else { | |
| const tile = row.querySelector(`.pairwise-tile[data-value="${val}"]`); | |
| if (tile) tile.classList.add('selected'); | |
| } | |
| debugLog(`[PAIRWISE] Restored multi-dim: ${schema}/${dim} = ${val}`); | |
| }); | |
| }); | |
| // Restore justification data | |
| document.querySelectorAll('.pairwise-justification').forEach(div => { | |
| const schema = div.getAttribute('data-schema'); | |
| if (!schema || !currentAnnotations[schema] || !currentAnnotations[schema]['justification']) return; | |
| try { | |
| const jdata = JSON.parse(currentAnnotations[schema]['justification']); | |
| const hiddenInput = div.querySelector('.pairwise-justification-value'); | |
| if (hiddenInput) { | |
| hiddenInput.value = currentAnnotations[schema]['justification']; | |
| hiddenInput.setAttribute('data-server-set', 'true'); | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| } | |
| if (jdata.reasons) { | |
| div.querySelectorAll('.pairwise-reason-cb').forEach(cb => { | |
| cb.checked = jdata.reasons.includes(cb.value); | |
| }); | |
| } | |
| if (jdata.rationale) { | |
| const ta = div.querySelector('.pairwise-rationale-textarea'); | |
| if (ta) { | |
| ta.value = jdata.rationale; | |
| updatePairwiseRationaleCounter(ta); | |
| } | |
| } | |
| } catch(e) { | |
| debugLog('[PAIRWISE] Error restoring justification:', e); | |
| } | |
| }); | |
| } | |
| /** | |
| * Update the rationale character counter. | |
| * @param {HTMLElement} textarea - The rationale textarea | |
| */ | |
| function updatePairwiseRationaleCounter(textarea) { | |
| const schema = textarea.getAttribute('data-schema'); | |
| const minChars = parseInt(textarea.getAttribute('data-min-chars') || '0', 10); | |
| const counter = textarea.closest('.pairwise-justification').querySelector('.pairwise-rationale-counter'); | |
| if (!counter) return; | |
| const len = textarea.value.length; | |
| counter.textContent = `${len} / ${minChars} characters`; | |
| counter.classList.toggle('insufficient', len < minChars && minChars > 0); | |
| // Save justification data | |
| savePairwiseJustification(schema); | |
| } | |
| /** | |
| * Save pairwise justification (reasons + rationale) to hidden input. | |
| */ | |
| function savePairwiseJustification(schema) { | |
| const div = document.querySelector(`.pairwise-justification[data-schema="${schema}"]`); | |
| if (!div) return; | |
| const reasons = []; | |
| div.querySelectorAll('.pairwise-reason-cb:checked').forEach(cb => reasons.push(cb.value)); | |
| const ta = div.querySelector('.pairwise-rationale-textarea'); | |
| const rationale = ta ? ta.value : ''; | |
| const data = JSON.stringify({ reasons: reasons, rationale: rationale }); | |
| const input = div.querySelector('.pairwise-justification-value'); | |
| if (input) { | |
| input.value = data; | |
| input.setAttribute('data-modified', 'true'); | |
| input.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| } | |
| // Export pairwise functions globally | |
| window.initPairwiseAnnotation = initPairwiseAnnotation; | |
| window.selectPairwiseTile = selectPairwiseTile; | |
| window.selectPairwiseOption = selectPairwiseOption; | |
| window.updatePairwiseScaleDisplay = updatePairwiseScaleDisplay; | |
| window.restorePairwiseAnnotations = restorePairwiseAnnotations; | |
| window.updatePairwiseRationaleCounter = updatePairwiseRationaleCounter; | |
| window.savePairwiseJustification = savePairwiseJustification; | |
| // ======================================== | |
| // BWS (BEST-WORST SCALING) ANNOTATION HANDLERS | |
| // ======================================== | |
| /** | |
| * Initialize BWS annotation interface. | |
| * Called after DOMContentLoaded and after forms are generated. | |
| */ | |
| function initBwsAnnotation() { | |
| const bwsForms = document.querySelectorAll('.annotation-form.bws'); | |
| if (bwsForms.length === 0) return; | |
| debugLog('[BWS] Initializing BWS annotation'); | |
| // Setup tile click handlers | |
| document.querySelectorAll('.bws-tile').forEach(tile => { | |
| tile.addEventListener('click', function() { | |
| selectBwsTile(this); | |
| }); | |
| tile.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| selectBwsTile(this); | |
| } | |
| }); | |
| }); | |
| // Populate BWS items display from var_elems | |
| populateBwsItemsDisplay(); | |
| debugLog('[BWS] Initialization complete'); | |
| } | |
| /** | |
| * Populate BWS items display from the bws_items var_elems data. | |
| */ | |
| function populateBwsItemsDisplay() { | |
| const bwsForms = document.querySelectorAll('.annotation-form.bws'); | |
| if (bwsForms.length === 0) return; | |
| // Read BWS items from var_elems <script> tag | |
| let bwsItems = null; | |
| const bwsItemsScript = document.getElementById('bws_items'); | |
| if (bwsItemsScript) { | |
| try { | |
| bwsItems = JSON.parse(bwsItemsScript.textContent); | |
| } catch (e) { | |
| debugLog('[BWS] Error parsing bws_items JSON:', e); | |
| } | |
| } | |
| if (!bwsItems || !Array.isArray(bwsItems) || bwsItems.length === 0) { | |
| debugLog('[BWS] No BWS items data found'); | |
| return; | |
| } | |
| debugLog('[BWS] Populating items display with', bwsItems.length, 'items'); | |
| // Build items display for each BWS form | |
| bwsForms.forEach(form => { | |
| const displayContainer = form.querySelector('.bws-items-display'); | |
| if (!displayContainer) return; | |
| let html = '<div class="bws-items-list">'; | |
| bwsItems.forEach(item => { | |
| const pos = escapeHtml(item.position || ''); | |
| const text = escapeHtml(item.text || ''); | |
| html += ` | |
| <div class="bws-item" data-position="${pos}"> | |
| <span class="bws-item-label">${pos}.</span> | |
| <span class="bws-item-text">${text}</span> | |
| </div>`; | |
| }); | |
| html += '</div>'; | |
| displayContainer.innerHTML = html; | |
| }); | |
| // Hide the standard "Text to Annotate" section | |
| const instanceTextContainer = document.querySelector('.instance-text-container'); | |
| if (instanceTextContainer) { | |
| instanceTextContainer.style.display = 'none'; | |
| } | |
| const textHeading = document.querySelector('h5.mb-3'); | |
| if (textHeading && textHeading.textContent.includes('Text to Annotate')) { | |
| textHeading.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Select a BWS tile (best or worst). | |
| * @param {HTMLElement} tile - The tile element clicked | |
| */ | |
| function selectBwsTile(tile) { | |
| const schema = tile.getAttribute('data-schema'); | |
| const value = tile.getAttribute('data-value'); | |
| const role = tile.getAttribute('data-role'); // "best" or "worst" | |
| const form = tile.closest('form'); | |
| if (!form) return; | |
| debugLog(`[BWS] Selecting tile: schema=${schema}, value=${value}, role=${role}`); | |
| // Prevent selecting the same item as both best and worst | |
| const otherRole = role === 'best' ? 'worst' : 'best'; | |
| const otherRoleClass = role === 'best' ? '.bws-worst-tile' : '.bws-best-tile'; | |
| const otherSelected = form.querySelector(`${otherRoleClass}.selected`); | |
| if (otherSelected && otherSelected.getAttribute('data-value') === value) { | |
| debugLog(`[BWS] Blocked: cannot select same item as both best and worst`); | |
| return; | |
| } | |
| // Deselect all tiles of the same role in this form | |
| const roleClass = role === 'best' ? '.bws-best-tile' : '.bws-worst-tile'; | |
| form.querySelectorAll(roleClass).forEach(t => t.classList.remove('selected')); | |
| // Select this tile | |
| tile.classList.add('selected'); | |
| // Disable the same item in the other role, enable all others | |
| form.querySelectorAll(otherRoleClass).forEach(t => { | |
| if (t.getAttribute('data-value') === value) { | |
| t.classList.add('bws-disabled'); | |
| } else { | |
| t.classList.remove('bws-disabled'); | |
| } | |
| }); | |
| // Update the corresponding hidden input | |
| const labelName = role; // "best" or "worst" | |
| const hiddenInput = form.querySelector(`.bws-value[label_name="${labelName}"]`); | |
| if (hiddenInput) { | |
| hiddenInput.value = value; | |
| hiddenInput.setAttribute('data-modified', 'true'); | |
| registerAnnotation(hiddenInput); | |
| hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| // Update validation | |
| validateRequiredFields(); | |
| } | |
| /** | |
| * Validate that best and worst selections are different. | |
| * @param {HTMLElement} form - The BWS form | |
| */ | |
| function validateBwsSelection(form) { | |
| const bestInput = form.querySelector('.bws-value[label_name="best"]'); | |
| const worstInput = form.querySelector('.bws-value[label_name="worst"]'); | |
| const errorDiv = form.querySelector('.bws-validation-error'); | |
| if (!bestInput || !worstInput) return; | |
| const bestVal = bestInput.value; | |
| const worstVal = worstInput.value; | |
| if (bestVal && worstVal && bestVal === worstVal) { | |
| if (errorDiv) errorDiv.style.display = 'block'; | |
| // Clear the more recently selected one — we detect by checking which role | |
| // was just selected. Since we can't easily tell, clear worst. | |
| worstInput.value = ''; | |
| form.querySelectorAll('.bws-worst-tile').forEach(t => t.classList.remove('selected')); | |
| registerAnnotation(worstInput); | |
| debugLog('[BWS] Validation error: best == worst, cleared worst'); | |
| } else { | |
| if (errorDiv) errorDiv.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Restore BWS annotation state from saved annotations. | |
| * Called during populateInputValues. | |
| */ | |
| function restoreBwsAnnotations() { | |
| if (!currentAnnotations) return; | |
| debugLog('[BWS] Restoring BWS annotations'); | |
| document.querySelectorAll('.annotation-form.bws').forEach(form => { | |
| // Restore best | |
| const bestInput = form.querySelector('.bws-value[label_name="best"]'); | |
| if (bestInput) { | |
| const schema = bestInput.getAttribute('schema'); | |
| if (schema && currentAnnotations[schema] && currentAnnotations[schema]['best']) { | |
| const savedValue = currentAnnotations[schema]['best']; | |
| bestInput.value = savedValue; | |
| bestInput.setAttribute('data-modified', 'true'); | |
| const tile = form.querySelector(`.bws-best-tile[data-value="${savedValue}"]`); | |
| if (tile) tile.classList.add('selected'); | |
| debugLog(`[BWS] Restored best: ${schema}/best = ${savedValue}`); | |
| } | |
| } | |
| // Restore worst | |
| const worstInput = form.querySelector('.bws-value[label_name="worst"]'); | |
| if (worstInput) { | |
| const schema = worstInput.getAttribute('schema'); | |
| if (schema && currentAnnotations[schema] && currentAnnotations[schema]['worst']) { | |
| const savedValue = currentAnnotations[schema]['worst']; | |
| worstInput.value = savedValue; | |
| worstInput.setAttribute('data-modified', 'true'); | |
| const tile = form.querySelector(`.bws-worst-tile[data-value="${savedValue}"]`); | |
| if (tile) tile.classList.add('selected'); | |
| debugLog(`[BWS] Restored worst: ${schema}/worst = ${savedValue}`); | |
| } | |
| } | |
| }); | |
| } | |
| // Export BWS functions globally | |
| window.initBwsAnnotation = initBwsAnnotation; | |
| window.selectBwsTile = selectBwsTile; | |
| window.restoreBwsAnnotations = restoreBwsAnnotations; | |
| /** | |
| * Populate dynamic schema content from instance data. | |
| * | |
| * Schemas like extractive_qa, text_edit, error_span, card_sort, and conjoint | |
| * need data from the current instance (question text, source text, items list, etc.) | |
| * that is NOT available at schema generation time. This function fetches the | |
| * instance data and injects it into the appropriate DOM containers. | |
| */ | |
| async function populateDynamicSchemaContent() { | |
| // Check if any dynamic schemas exist on the page | |
| const dynamicForms = document.querySelectorAll( | |
| '.shadcn-extractive-qa-container, .shadcn-text-edit-container, ' + | |
| '.shadcn-error-span-container, .shadcn-card-sort-container, ' + | |
| '.shadcn-conjoint-container' | |
| ); | |
| if (dynamicForms.length === 0) return; | |
| // Fetch instance data from server | |
| let instanceData = null; | |
| try { | |
| const resp = await fetch('/api/instance_data'); | |
| if (resp.ok) { | |
| instanceData = resp.json ? await resp.json() : null; | |
| } | |
| } catch (e) { | |
| // Fallback: try to parse from embedded JSON | |
| debugLog('[DynamicSchema] Could not fetch instance data, using fallback'); | |
| } | |
| // Fallback: read from the embedded <script id="instance_data"> tag (full instance data) | |
| if (!instanceData) { | |
| try { | |
| const instanceDataScript = document.getElementById('instance_data'); | |
| if (instanceDataScript) { | |
| instanceData = JSON.parse(instanceDataScript.textContent); | |
| } | |
| } catch (e) { | |
| debugLog('[DynamicSchema] Could not parse embedded instance_data'); | |
| } | |
| } | |
| // Second fallback: read from the embedded <script id="instance"> tag (text only) | |
| if (!instanceData) { | |
| try { | |
| const instanceScript = document.getElementById('instance'); | |
| if (instanceScript) { | |
| instanceData = JSON.parse(instanceScript.textContent); | |
| } | |
| } catch (e) { | |
| debugLog('[DynamicSchema] Could not parse embedded instance data'); | |
| } | |
| } | |
| if (!instanceData) { | |
| debugLog('[DynamicSchema] No instance data available'); | |
| return; | |
| } | |
| // Store globally for schema inline scripts | |
| window.currentInstanceData = instanceData; | |
| // Get the instance text from the DOM (already rendered by the template) | |
| const instanceTextEl = document.getElementById('text-content') || document.getElementById('instance-text'); | |
| const instanceText = instanceTextEl ? (instanceTextEl.textContent || '') : ''; | |
| dynamicForms.forEach(form => { | |
| const type = form.getAttribute('data-annotation-type'); | |
| const schemaName = form.getAttribute('data-schema-name'); | |
| switch (type) { | |
| case 'extractive_qa': | |
| populateExtractiveQa(form, schemaName, instanceData, instanceText); | |
| break; | |
| case 'text_edit': | |
| populateTextEdit(form, schemaName, instanceData); | |
| break; | |
| case 'error_span': | |
| populateErrorSpan(form, schemaName, instanceData, instanceText); | |
| break; | |
| case 'card_sort': | |
| populateCardSort(form, schemaName, instanceData); | |
| break; | |
| case 'conjoint': | |
| populateConjoint(form, schemaName, instanceData); | |
| break; | |
| } | |
| }); | |
| } | |
| function populateExtractiveQa(form, schemaName, data, instanceText) { | |
| // Populate question text | |
| const questionField = form.getAttribute('data-question-field') || 'question'; | |
| const questionEl = form.querySelector('.eqa-question-text'); | |
| if (questionEl && data[questionField]) { | |
| questionEl.textContent = data[questionField]; | |
| } | |
| // Populate passage text (for selection) | |
| const passageField = form.getAttribute('data-passage-field') || 'passage'; | |
| const passageEl = document.getElementById(schemaName + '-passage'); | |
| if (passageEl) { | |
| const passageText = data[passageField] || instanceText; | |
| if (passageText && !passageEl.textContent.trim()) { | |
| passageEl.textContent = passageText; | |
| } | |
| } | |
| } | |
| function populateTextEdit(form, schemaName, data) { | |
| const sourceField = form.getAttribute('data-source-field'); | |
| if (!sourceField || !data[sourceField]) return; | |
| const sourceText = data[sourceField]; | |
| // Set the source (original) text display | |
| const sourceEl = document.getElementById(schemaName + '-source-text'); | |
| if (sourceEl && !sourceEl.textContent.trim()) { | |
| sourceEl.textContent = sourceText; | |
| } | |
| // Pre-fill the editor textarea with source text | |
| const editor = document.getElementById(schemaName + '-editor'); | |
| if (editor && !editor.value.trim()) { | |
| editor.value = sourceText; | |
| } | |
| } | |
| function populateErrorSpan(form, schemaName, data, instanceText) { | |
| const textContainer = document.getElementById(schemaName + '-text'); | |
| if (textContainer && !textContainer.textContent.trim()) { | |
| textContainer.textContent = instanceText; | |
| } | |
| // Hide the duplicate "Text to Annotate" section — the error span container | |
| // IS the interactive text area and showing the same text twice is confusing | |
| const instanceTextSection = document.getElementById('instance-text'); | |
| if (instanceTextSection) { | |
| instanceTextSection.style.display = 'none'; | |
| } | |
| } | |
| function populateCardSort(form, schemaName, data) { | |
| const itemsField = form.getAttribute('data-items-field') || 'items'; | |
| const items = data[itemsField]; | |
| if (!items || !Array.isArray(items)) return; | |
| const sourceItems = document.getElementById(schemaName + '-source-items'); | |
| if (!sourceItems || sourceItems.children.length > 0) return; | |
| items.forEach(function(item, idx) { | |
| const card = document.createElement('div'); | |
| card.className = 'card-sort-card'; | |
| card.draggable = true; | |
| card.setAttribute('data-card-text', item); | |
| card.textContent = item; | |
| card.ondragstart = function(e) { | |
| e.dataTransfer.setData('text/plain', item); | |
| e.dataTransfer.setData('application/x-source-group', '__source__'); | |
| card.classList.add('card-sort-dragging'); | |
| }; | |
| card.ondragend = function() { | |
| card.classList.remove('card-sort-dragging'); | |
| }; | |
| sourceItems.appendChild(card); | |
| }); | |
| } | |
| function populateConjoint(form, schemaName, data) { | |
| // Check if profiles are in the instance data | |
| const profilesField = form.getAttribute('data-profiles-field'); | |
| let profiles = null; | |
| if (profilesField && data[profilesField]) { | |
| profiles = data[profilesField]; | |
| } | |
| // If no profiles in data, generate from config attributes | |
| if (!profiles) { | |
| // Read attribute definitions from the form's data-attributes or from inline config | |
| const attrCells = form.querySelectorAll('.conjoint-attr-value'); | |
| if (attrCells.length === 0) return; | |
| // Collect unique attributes | |
| const attrs = {}; | |
| attrCells.forEach(cell => { | |
| const attrName = cell.getAttribute('data-attr'); | |
| if (attrName && !attrs[attrName]) attrs[attrName] = []; | |
| }); | |
| // Try to read levels from inline script config | |
| const formScript = form.closest('.annotation_schema'); | |
| const scriptEl = formScript ? formScript.querySelector('script') : null; | |
| let configData = null; | |
| if (scriptEl) { | |
| try { | |
| // Look for conjointConfig in the script text | |
| const scriptText = scriptEl.textContent; | |
| const match = scriptText.match(/var\s+conjointConfig\s*=\s*(\{[^;]+\})/); | |
| if (match) configData = JSON.parse(match[1]); | |
| } catch (e) {} | |
| } | |
| // If we have config data, use it to generate random profiles | |
| if (configData && configData.attributes) { | |
| const profileCards = form.querySelectorAll('.conjoint-profile-card'); | |
| profileCards.forEach((card, idx) => { | |
| const profileNum = card.getAttribute('data-profile'); | |
| configData.attributes.forEach(attr => { | |
| const cell = card.querySelector(`.conjoint-attr-value[data-attr="${attr.name}"]`); | |
| if (cell && attr.levels && attr.levels.length > 0) { | |
| // Use a deterministic index based on profile num and attribute | |
| // to vary levels across profiles for each instance | |
| const instanceId = document.getElementById('instance_id'); | |
| const seed = (instanceId ? instanceId.value.length : 0) + idx; | |
| const levelIdx = (seed + attr.name.length + idx * 7) % attr.levels.length; | |
| cell.textContent = attr.levels[levelIdx]; | |
| } | |
| }); | |
| }); | |
| } | |
| } else if (Array.isArray(profiles)) { | |
| // Profiles from data | |
| const profileCards = form.querySelectorAll('.conjoint-profile-card'); | |
| profiles.forEach((profile, idx) => { | |
| if (idx < profileCards.length) { | |
| const card = profileCards[idx]; | |
| Object.keys(profile).forEach(attrName => { | |
| const cell = card.querySelector(`.conjoint-attr-value[data-attr="${attrName}"]`); | |
| if (cell) cell.textContent = profile[attrName]; | |
| }); | |
| } | |
| }); | |
| } | |
| } | |