// 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 = `
';
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