Spaces:
Running
Running
| /** | |
| * 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<void>} | |
| */ | |
| 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<Object>} | |
| */ | |
| 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 += `<button class="help-lang-btn${active}" data-lang="${lang}" title="${langLabels[lang]}">${langLabels[lang]}</button>`; | |
| }); | |
| return ` | |
| <div class="help-popup-header"> | |
| <span class="help-popup-title">${this._escapeHtml(sectionName)}</span> | |
| <button class="help-popup-close" title="Close" aria-label="Close">×</button> | |
| </div> | |
| <div class="help-popup-body">${this._escapeHtml(bodyText)}</div> | |
| <div class="help-popup-footer">${langButtons}</div> | |
| `; | |
| } | |
| /** | |
| * 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; | |