// 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 = `
${groupConfig.title ? `

${this.escapeHtml(groupConfig.title)}

` : ''} ${groupConfig.description ? `

${this.escapeHtml(groupConfig.description)}

` : ''}
`; if (groupConfig.collapsible) { headerHtml += ` `; } headerHtml += '
'; group.innerHTML = headerHtml + '
'; // 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 : use getAttribute('value') which returns the HTML attribute // - For