/** * HelpTooltip - Displays help icons (?) next to section headers with multilingual popup explanations. * Loads help content from a JSON file and supports EN/VI/JA language switching. * Requirements: 28.1, 28.2, 28.3, 28.4, 28.5, 28.6, 28.7, 28.8, 28.9 */ class HelpTooltip { /** * @param {Object} [options] * @param {string} [options.helpContentPath] - Path to help JSON file (default: './help/help-content.json') * @param {string} [options.defaultLang] - Default language code (default: 'en') * @param {string} [options.storageKey] - localStorage key for language (default: 'onnx_explorer_help_lang') */ constructor(options = {}) { this._helpContentPath = options.helpContentPath || './help/help-content.json'; this._defaultLang = options.defaultLang || 'en'; this._storageKey = options.storageKey || 'onnx_explorer_help_lang'; this._helpContent = null; this._currentLang = this._loadLanguage(); this._currentPopup = null; this._currentIconSection = null; // Bound handler for closing popup on outside click this._onDocumentClick = this._onDocumentClick.bind(this); // Section key → display name mapping this._sectionNames = { metadata: 'Metadata', inputOutput: 'Inputs & Outputs', initializers: 'Initializers', layerStats: 'Layer Statistics', modelComplexity: 'Model Complexity', opsetCompatibility: 'Opset Compatibility', modelGraph: 'Model Graph', nodeDetails: 'Node Details' }; // Section key → container selector + header text for lookup this._sectionMap = { metadata: { selector: '#metadataContainer', headerText: 'Metadata' }, inputOutput: { selector: '#inputOutputContainer', headerText: 'Inputs & Outputs' }, initializers: { selector: '#initializerContainer', headerText: 'Initializers' }, layerStats: { selector: '#layerStatsContainer', headerText: 'Layer Statistics', noCard: true }, modelComplexity: { selector: '#modelComplexityContainer',headerText: 'Model Complexity', noCard: true }, opsetCompatibility: { selector: '#opsetCheckerContainer', headerText: 'Opset Compatibility', noCard: true }, modelGraph: { selector: null, headerText: 'Model Graph' }, nodeDetails: { selector: null, headerText: 'Node Details' } }; } // ─── Public API ─────────────────────────────────────────────────────────── /** * Initialize: load help content JSON, inject icons into section headers. * @returns {Promise} */ async init() { try { this._helpContent = await this.loadHelpContent(); } catch (err) { console.warn('[HelpTooltip] Failed to load help content:', err); this._helpContent = null; } this._injectAllIcons(); document.addEventListener('click', this._onDocumentClick); } /** * Fetch help content from JSON file. * @returns {Promise} */ async loadHelpContent() { const response = await fetch(this._helpContentPath); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); } /** * Inject a help icon (?) next to a section header element. * @param {string} sectionKey - Key of the section (e.g. 'metadata') * @param {HTMLElement} headerElement - The heading element to inject icon next to */ injectIcon(sectionKey, headerElement) { if (!headerElement) return; // Avoid duplicate injection if (headerElement.querySelector('.help-icon')) return; const icon = document.createElement('span'); icon.className = 'help-icon'; icon.setAttribute('data-section', sectionKey); icon.setAttribute('title', 'Help'); icon.setAttribute('role', 'button'); icon.setAttribute('tabindex', '0'); icon.setAttribute('aria-label', `Help for ${this._sectionNames[sectionKey] || sectionKey}`); icon.textContent = '?'; icon.addEventListener('click', (e) => { e.stopPropagation(); this.showPopup(sectionKey, icon); }); icon.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); this.showPopup(sectionKey, icon); } }); headerElement.appendChild(icon); } /** * Show a help popup positioned near the anchor icon. * @param {string} sectionKey - Key of the section * @param {HTMLElement} anchorElement - The icon element to position popup near */ showPopup(sectionKey, anchorElement) { // Close any existing popup this.closePopup(); this._currentIconSection = sectionKey; const popup = document.createElement('div'); popup.className = 'help-popup'; popup.setAttribute('role', 'dialog'); popup.setAttribute('aria-label', `Help: ${this._sectionNames[sectionKey] || sectionKey}`); popup.innerHTML = this._buildPopupHTML(sectionKey); document.body.appendChild(popup); this._currentPopup = popup; // Position popup near the icon this._positionPopup(popup, anchorElement); // Attach language button handlers this._attachLangHandlers(popup, sectionKey); // Attach close button handler const closeBtn = popup.querySelector('.help-popup-close'); if (closeBtn) { closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.closePopup(); }); } // Prevent clicks inside popup from closing it popup.addEventListener('click', (e) => { e.stopPropagation(); }); } /** * Close the current popup. */ closePopup() { if (this._currentPopup && this._currentPopup.parentNode) { this._currentPopup.parentNode.removeChild(this._currentPopup); } this._currentPopup = null; this._currentIconSection = null; } /** * Set the display language and persist to localStorage. * @param {string} lang - Language code ('en', 'vi', 'ja') */ setLanguage(lang) { if (['en', 'vi', 'ja'].indexOf(lang) === -1) return; this._currentLang = lang; this._saveLanguage(lang); } /** * Get the current display language. * @returns {string} */ getLanguage() { return this._currentLang; } /** * Destroy: remove event listeners, close popup, remove injected icons. */ destroy() { this.closePopup(); document.removeEventListener('click', this._onDocumentClick); // Remove all injected help icons const icons = document.querySelectorAll('.help-icon'); icons.forEach((icon) => { if (icon.parentNode) { icon.parentNode.removeChild(icon); } }); } // ─── Private ────────────────────────────────────────────────────────────── /** * Load saved language from localStorage, or return default. * @returns {string} */ _loadLanguage() { try { const saved = localStorage.getItem(this._storageKey); if (saved && ['en', 'vi', 'ja'].indexOf(saved) !== -1) { return saved; } } catch (e) { // localStorage unavailable } return this._defaultLang; } /** * Save language to localStorage. * @param {string} lang */ _saveLanguage(lang) { try { localStorage.setItem(this._storageKey, lang); } catch (e) { // localStorage unavailable } } /** * Inject help icons into all known section headers. */ _injectAllIcons() { for (const sectionKey of Object.keys(this._sectionMap)) { const config = this._sectionMap[sectionKey]; const headerEl = this._findHeaderElement(sectionKey, config); if (headerEl) { this.injectIcon(sectionKey, headerEl); } } // For sections without card wrappers, observe for dynamic content this._observeDynamicSections(); } /** * Find the header element for a given section. * @param {string} sectionKey * @param {Object} config - { selector, headerText, noCard } * @returns {HTMLElement|null} */ _findHeaderElement(sectionKey, config) { // For sections with a container selector and card wrapper if (config.selector) { const container = document.querySelector(config.selector); if (!container) return null; if (config.noCard) { // No card wrapper — look for a heading inside the container const heading = container.querySelector('h5, h6, .card-header h5, .card-header h6'); if (heading) return heading; // Will be handled by MutationObserver return null; } // Find the .card-header ancestor and its h5/h6 const card = container.closest('.card'); if (card) { const header = card.querySelector('.card-header h5, .card-header h6'); if (header) return header; } } // For sections without a selector (modelGraph, nodeDetails) — search by header text if (!config.selector || !document.querySelector(config.selector)) { const allHeaders = document.querySelectorAll('.card-header h5, .card-header h6'); for (const h of allHeaders) { if (h.textContent.trim() === config.headerText) { return h; } } } return null; } /** * Observe dynamic sections (layerStats, modelComplexity, opsetCompatibility) * that render content after init. Inject icons when headings appear. */ _observeDynamicSections() { const dynamicKeys = ['layerStats', 'modelComplexity', 'opsetCompatibility']; dynamicKeys.forEach((sectionKey) => { const config = this._sectionMap[sectionKey]; if (!config.selector) return; const container = document.querySelector(config.selector); if (!container) return; // Already injected? if (container.querySelector('.help-icon')) return; const observer = new MutationObserver(() => { // Check if a heading appeared const heading = container.querySelector('h5, h6, .card-header h5, .card-header h6'); if (heading && !heading.querySelector('.help-icon')) { this.injectIcon(sectionKey, heading); observer.disconnect(); } }); observer.observe(container, { childList: true, subtree: true }); }); } /** * Handle document click — close popup if click is outside popup and icon. * @param {Event} e */ _onDocumentClick(e) { if (!this._currentPopup) return; // Check if click is inside popup if (this._currentPopup.contains(e.target)) return; // Check if click is on a help icon if (e.target.classList && e.target.classList.contains('help-icon')) return; this.closePopup(); } /** * Build the inner HTML for the popup. * @param {string} sectionKey * @returns {string} */ _buildPopupHTML(sectionKey) { const sectionName = this._sectionNames[sectionKey] || sectionKey; const bodyText = this._getHelpText(sectionKey); const langs = ['en', 'vi', 'ja']; const langLabels = { en: 'EN', vi: 'VI', ja: 'JA' }; let langButtons = ''; langs.forEach((lang) => { const active = lang === this._currentLang ? ' active' : ''; langButtons += ``; }); return `
${this._escapeHtml(sectionName)}
${this._escapeHtml(bodyText)}
`; } /** * Get help text for a section in the current language. * @param {string} sectionKey * @returns {string} */ _getHelpText(sectionKey) { if (!this._helpContent) { return 'Help content unavailable'; } const section = this._helpContent[sectionKey]; if (!section) { return 'Help content unavailable'; } return section[this._currentLang] || section[this._defaultLang] || section['en'] || 'Help content unavailable'; } /** * Position the popup near the anchor element, ensuring it stays within viewport. * @param {HTMLElement} popup * @param {HTMLElement} anchor */ _positionPopup(popup, anchor) { const rect = anchor.getBoundingClientRect(); const scrollX = window.pageXOffset || document.documentElement.scrollLeft; const scrollY = window.pageYOffset || document.documentElement.scrollTop; // Default: position below the icon let top = rect.bottom + scrollY + 6; let left = rect.left + scrollX; // Apply position so we can measure popup dimensions popup.style.position = 'absolute'; popup.style.top = top + 'px'; popup.style.left = left + 'px'; // Adjust if popup overflows right edge const popupRect = popup.getBoundingClientRect(); const viewportWidth = window.innerWidth; if (popupRect.right > viewportWidth - 10) { left = Math.max(10, viewportWidth - popupRect.width - 10 + scrollX); popup.style.left = left + 'px'; } // Adjust if popup overflows bottom edge const viewportHeight = window.innerHeight; if (popupRect.bottom > viewportHeight - 10) { // Position above the icon instead top = rect.top + scrollY - popupRect.height - 6; if (top < scrollY + 10) { top = scrollY + 10; } popup.style.top = top + 'px'; } } /** * Attach click handlers to language buttons inside the popup. * @param {HTMLElement} popup * @param {string} sectionKey */ _attachLangHandlers(popup, sectionKey) { const buttons = popup.querySelectorAll('.help-lang-btn'); buttons.forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const lang = btn.getAttribute('data-lang'); this.setLanguage(lang); // Update body text const body = popup.querySelector('.help-popup-body'); if (body) { body.textContent = this._getHelpText(sectionKey); } // Update active state on buttons buttons.forEach((b) => b.classList.remove('active')); btn.classList.add('active'); }); }); } /** * Escape HTML special characters. * @param {string} str * @returns {string} */ _escapeHtml(str) { const div = document.createElement('div'); div.appendChild(document.createTextNode(String(str))); return div.innerHTML; } } window.HelpTooltip = HelpTooltip;