1/* public/js/init-language-picker.js 2 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3 ⢠Creates a language dropdown that attaches to the Language menu button 4 ⢠When the Language button is clicked, a dropdown panel appears 5 with the configured language options 6 ⢠Remembers last choice in localStorage 7 ⢠Honors ?lang=<code> in the URL (2026-09-02), so a link can open the 8 map in a language the project offers. See js/i18n/initial-language.js 9 for the precedence rule; the URL wins, but only for an offered code. 10 ⢠Guards against duplicates (hot-reload safe) 11 *****************************************************************/ 12 13import { resolveInitialLanguage } from './i18n/initial-language.js'; 14 15export function initLanguagePicker () { 16 17 /* 0ï¸â£ project-level flag â also auto-enable when 2+ custom languages exist 18 (handles legacy projects saved before the enableLanguageToggle flag was added) */ 19 const ps = window.projectSettings || {}; 20 const hasLanguages = Array.isArray(ps.customLanguages) && ps.customLanguages.length >= 2; 21 if (!ps.enableLanguageToggle && !hasLanguages) return; 22 23 /* 1ï¸â£ duplicate guard */ 24 if (document.getElementById('language-picker-dropdown')) return; 25 26 /* 2ï¸â£ build the language list */ 27 const OPTIONS = 28 (Array.isArray(window.projectSettings?.customLanguages) && 29 window.projectSettings.customLanguages.length) 30 ? window.projectSettings.customLanguages 31 : [ 32 { code:'en', label:'English â EN' }, 33 { code:'es', label:'Español â ES' }, 34 { code:'vi', label:'Tiếng Viá»t â VI'}, 35 { code:'zh', label:'䏿 â ZH' }, 36 { code:'yi', label:'××Ö´××ש â YI' } 37 ]; 38 39 // Need at least 2 languages to show a picker 40 if (OPTIONS.length < 2) return; 41 42 /* 3ï¸â£ create the dropdown panel */ 43 const dropdown = document.createElement('div'); 44 dropdown.id = 'language-picker-dropdown'; 45 dropdown.style.cssText = ` 46 display: none; 47 position: fixed; 48 z-index: 2000; 49 background: #fff; 50 border-radius: 8px; 51 box-shadow: 0 4px 20px rgba(0,0,0,0.25); 52 padding: 6px 0; 53 min-width: 180px; 54 `; 55 56 const current = localStorage.getItem('wm_lang') || 'en'; 57 58 OPTIONS.forEach(({ code, label }) => { 59 const item = document.createElement('button'); 60 item.type = 'button'; 61 item.dataset.lang = code; 62 item.textContent = label; 63 item.style.cssText = ` 64 display: block; 65 width: 100%; 66 border: none; 67 background: ${code === current ? '#f0f0f0' : 'transparent'}; 68 padding: 8px 16px; 69 text-align: left; 70 font-size: 14px; 71 cursor: pointer; 72 font-weight: ${code === current ? '600' : '400'}; 73 `; 74 item.addEventListener('mouseenter', () => { item.style.background = '#e9ecef'; }); 75 item.addEventListener('mouseleave', () => { 76 item.style.background = item.dataset.lang === current ? '#f0f0f0' : 'transparent'; 77 }); 78 item.addEventListener('click', () => selectLanguage(code)); 79 dropdown.appendChild(item); 80 }); 81 82 document.body.appendChild(dropdown); 83 84 /* 4ï¸â£ find every Language anchor and wire each up. 85 * 86 * The topnav layout renders BOTH a visible topnav button and a 87 * sidebar mobile-fallback button, so there can be multiple elements 88 * with data-mid="languageToggle". Attaching only to the first one 89 * (via querySelector) meant the topnav button clicked by the user 90 * had no handler and appeared broken. Wire every match so whichever 91 * anchor is visible responds. */ 92 const langBtns = Array.from( 93 document.querySelectorAll('[data-mid="languageToggle"], [data-role="btn-languageToggle"]') 94 ); 95 langBtns.forEach(btn => { 96 btn.addEventListener('click', (e) => { 97 e.stopPropagation(); 98 toggleDropdown(btn); 99 }); 100 }); 101 102 // Also create a hidden <select> so the menu-builder handler can still find it 103 const hiddenSel = document.createElement('select'); 104 hiddenSel.id = 'language-picker'; 105 hiddenSel.style.display = 'none'; 106 OPTIONS.forEach(({ code, label }) => { 107 const opt = document.createElement('option'); 108 opt.value = code; 109 opt.textContent = label; 110 hiddenSel.appendChild(opt); 111 }); 112 document.body.appendChild(hiddenSel); 113 114 /* 5ï¸â£ toggle/position logic */ 115 function toggleDropdown(anchor) { 116 const isOpen = dropdown.style.display !== 'none'; 117 if (isOpen) { 118 dropdown.style.display = 'none'; 119 return; 120 } 121
122 // Position relative to the anchor button 123 const rect = anchor.getBoundingClientRect(); 124 dropdown.style.display = 'block'; 125 126 // Place to the right of the button, aligned to top 127 let left = rect.right + 8; 128 let top = rect.top; 129 130 // If it would go off-screen right, place below instead 131 if (left + 200 > window.innerWidth) { 132 left = rect.left; 133 top = rect.bottom + 4; 134 } 135 136 // Ensure it doesn't go off-screen bottom 137 if (top + dropdown.offsetHeight > window.innerHeight) { 138 top = window.innerHeight - dropdown.offsetHeight - 8; 139 } 140 141 dropdown.style.left = left + 'px'; 142 dropdown.style.top = top + 'px'; 143 } 144 145 // Close on outside click. Ignore clicks on any language anchor so 146 // the toggle handler above still gets to decide whether to open. 147 document.addEventListener('click', (e) => { 148 if (dropdown.contains(e.target)) return; 149 if (langBtns.some(btn => btn === e.target || btn.contains(e.target))) return; 150 dropdown.style.display = 'none'; 151 }); 152 153 // Close on Escape 154 document.addEventListener('keydown', (e) => { 155 if (e.key === 'Escape') dropdown.style.display = 'none'; 156 }); 157 158 /* 6ï¸â£ language selection handler */ 159 async function selectLanguage(code) { 160 dropdown.style.display = 'none'; 161 localStorage.setItem('wm_lang', code); 162 163 if (code === 'off' || code === 'en') { 164 location.reload(); 165 return; 166 } 167 try { 168 const { translatePageTo } = await import('./global-translate.js'); 169 await translatePageTo(code); 170 } catch (err) { 171 console.error('[i18n] translatePageTo failed â', err); 172 } 173 174 // Update active highlight 175 dropdown.querySelectorAll('button').forEach(btn => { 176 const isActive = btn.dataset.lang === code; 177 btn.style.fontWeight = isActive ? '600' : '400'; 178 btn.style.background = isActive ? '#f0f0f0' : 'transparent'; 179 }); 180 } 181 182 /* 7ï¸â£ pick the opening language: ?lang= first, then the remembered choice */ 183 try { 184 const stored = (localStorage.getItem('wm_lang') || '').toLowerCase(); 185 const { code, fromUrl } = resolveInitialLanguage({ 186 urlLang: new URLSearchParams(window.location.search).get('lang'), 187 storedLang: stored, 188 offered: OPTIONS 189 }); 190 console.log('[i18n] opening language ' + JSON.stringify({ stored, code, fromUrl, url: location.search })); 191 192 if (fromUrl && stored && stored !== 'en' && stored !== 'off' && stored !== code) { 193 // The link names a different language from the one remembered. Boot 194 // has already started translating subtrees into the remembered one 195 // (retranslateNow() in search, details, map-core all read the value 196 // global-translate.js captured at import), and those calls finish 197 // AFTER anything done here, writing the old language back. Setting 198 // storage and reloading is the only ordering that cannot race; after 199 // the reload stored === code, so this branch is not taken again. 200 localStorage.setItem('wm_lang', code); 201 location.reload(); 202 return; 203 } 204 205 // A link that names a language becomes the remembered choice, so the 206 // next page the reader opens on this project stays in it. 207 if (fromUrl) localStorage.setItem('wm_lang', code); 208 if (code !== 'en') { 209 // Auto-translate on page load. English is never passed to 210 // selectLanguage() here: that path reloads the page, and ?lang=en 211 // would reload forever. 212 selectLanguage(code); 213 } 214 } catch {/* ignore */} 215}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.