PageSourceSearch

http://vortaro.cn/js/lookup-popup.js?v=20260803

js vortaro.cn collected 2026-10-06 13:15:15 UTC 14,869 bytes, 350 lines download raw bytes

1/**
2 * 划词弹窗查询模块(支持世界语词尾变化 + 弹窗内高亮滚动 + 手机端触摸)
3 * 逻辑:直接查word → 直接查pais → 词尾处理查word → 词尾处理查pais → 元音替换查word → 元音替换查pais → 未找到
4 */
5(function() {
6    'use strict';
7    if (window._lookupPopupLoaded) return;
8    window._lookupPopupLoaded = true;
9
10    // ==================== 弹窗 DOM ====================
11    var popup = document.createElement('div');
12    popup.id = 'lookupPopup';
13    popup.style.cssText = [
14        'display:none','position:fixed','z-index:99999','background:#fff',
15        'border:2px solid #006400','border-radius:8px','box-shadow:0 4px 20px rgba(0,0,0,0.3)',
16        'max-width:480px','max-height:380px','overflow:auto','font-family:monospace',
17        'font-size:14px','line-height:1.6'
18    ].join(';');
19
20    var header = document.createElement('div');
21    header.style.cssText = [
22        'background:#006400','color:#fff','padding:8px 12px','font-weight:bold',
23        'font-size:16px','display:flex','justify-content:space-between',
24        'align-items:center','border-radius:6px 6px 0 0','position:sticky',
25        'top:0','z-index:1'
26    ].join(';');
27
28    var title = document.createElement('span');
29    title.id = 'lookupPopupTitle';
30    title.textContent = '快速查询';
31
32    var closeBtn = document.createElement('span');
33    closeBtn.innerHTML = '✖';
34    closeBtn.style.cssText = 'cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px;';
35    closeBtn.onmouseover = function() { this.style.background = 'rgba(255,255,255,0.2)'; };
36    closeBtn.onmouseout = function() { this.style.background = 'transparent'; };
37    closeBtn.onclick = hidePopup;
38
39    header.appendChild(title);
40    header.appendChild(closeBtn);
41
42    var content = document.createElement('div');
43    content.id = 'lookupPopupContent';
44    content.style.cssText = 'padding:12px;';
45
46    popup.appendChild(header);
47    popup.appendChild(content);
48    document.body.appendChild(popup);
49
50    // ==================== 事件 ====================
51    document.addEventListener('mousedown', function(e) {
52        if (popup.style.display === 'block' && !popup.contains(e.target)) hidePopup();
53    });
54    document.addEventListener('keydown', function(e) {
55        if (e.key === 'Escape') hidePopup();
56    });
57
58    function hidePopup() {
59        popup.style.display = 'none';
60        window.getSelection().removeAllRanges();
61    }
62
63    // ==================== 选词 ====================
64    function getSelectedWord() {
65        var sel = window.getSelection();
66        if (!sel || sel.rangeCount === 0) return null;
67        var text = sel.toString().trim();
68        if (!text) return null;
69        if (/\s/.test(text)) return null;
70        if (text.length < 1 || text.length > 35) return null;
71        text = text.replace(/^[.,;:!?()""«»—\-\/\\[\]{}|]+|[.,;:!?()""«»—\-\/\\[\]{}|]+$/g, '');
72        if (!text || /\s/.test(text) || text.length < 1) return null;
73        return text;
74    }
75
76    // ==================== 监听 webBrowser(桌面 + 手机)====================
77    function initWebBrowserListener() {
78        var webBrowser = document.getElementById('webBrowser');
79        if (!webBrowser) { setTimeout(initWebBrowserListener, 500); return; }
80
81        // 桌面端:鼠标松开
82        webBrowser.addEventListener('mouseup', function(e) {
83            if (popup.contains(e.target)) return;
84            setTimeout(function() {
85                var word = getSelectedWord();
86                if (!word) return;
87                lookup(word, e.clientX, e.clientY);
88            }, 10);
89        });
90
91        // 移动端:触摸结束(手指选词后松手)
92        webBrowser.addEventListener('touchend', function(e) {
93            if (popup.contains(e.target)) return;
94            setTimeout(function() {
95                var word = getSelectedWord();
96                if (!word) return;
97                var touch = e.changedTouches[0];
98                lookup(word, touch.clientX, touch.clientY);
99            }, 100);
100        });
101    }
102    initWebBrowserListener();
103
104    // ==================== 世界语词尾处理 ====================
105    function removeSuffix(word, suffixes) {
106        for (var i = 0; i < suffixes.length; i++) {
107            if (word.endsWith(suffixes[i])) return word.slice(0, -suffixes[i].length);
108        }
109        return word;
110    }
111
112    /** 处理词尾:去 j/n/jn,分词/变位后缀替换为 i */
113    function processWordEnding(input) {
114        var ignoreSuffixes = ['jn', 'j', 'n'];
115        var replaceSuffixes = ['anta','ante','anto','inta','inte','into','onta','onte','onto',
116                               'ata','ate','ato','ita','ite','ito','ota','ote','oto',
117                               'as','is','os','us','u'];
118        var w = input.toLowerCase();
119        w = removeSuffix(w, ignoreSuffixes);
120        for (var i = 0; i < replaceSuffixes.length; i++) {
121            if (w.endsWith(replaceSuffixes[i])) {
122                w = w.slice(0, -replaceSuffixes[i].length) + 'i';
123                break;
124            }
125        }
126        return w;
127    }
128
129    /** 元音替换:如 amikini → amikina/amikine/amikino */
130    function getVowelReplacements(input) {
131        if (input.length <= 3) return [];
132        var lastChar = input[input.length - 1];
133        var v = lastChar.toLowerCase();
134        if (v !== 'a' && v !== 'e' && v !== 'i' && v !== 'o') return [];
135        var baseStr = input.slice(0, -1);
136        var all = ['a','e','i','o'];
137        return all.filter(function(x) { return x !== v; }).map(function(x) { return baseStr + x; });
138    }
139
140    // ==================== 查询流程:word优先 → pais兜底 ====================
141    function lookup(word, mouseX, mouseY) {
142        title.textContent = '查询: ' + word;
143        content.innerHTML = '<div style="color:#999;padding:20px;text-align:center;">查询中...</div>';
144        showPopup(mouseX, mouseY);
145
146        // 1. 直接查 word
147        doExactSearchWord(word, function(wordResults) {
148            if (wordResults && wordResults.length > 0) {
149                displayResults(wordResults, word, null, false);
150                return;
151            }
152
153            // 2. 直接查 pais
154            doExactSearchPais(word, function(paisResults) {
155                if (paisResults && paisResults.length > 0) {
156                    displayResults(paisResults, word, null, true);
157                    return;
158                }
159
160                // 3. 词尾处理
161                var processed = processWordEnding(word);
162                if (processed && processed !== word.toLowerCase()) {
163                    // 3a. 词尾处理查 word
164                    doExactSearchWord(processed, function(wordResults2) {
165                        if (wordResults2 && wordResults2.length > 0) {
166                            displayResults(wordResults2, word, processed, false);
167                            return;
168                        }
169
170                        // 3b. 词尾处理查 pais
171                        doExactSearchPais(processed, function(paisResults2) {
172                            if (paisResults2 && paisResults2.length > 0) {
173                                displayResults(paisResults2, word, processed, true);
174                                return;
175                            }
176
177                            // 4. 元音替换查
178                            tryVowelSearch(word, processed, function(results, candidate, isPais) {
179                                if (results && results.length > 0) {
180                                    displayResults(results, word, candidate, isPais);
181                                } else {
182                                    showNotFound(word);
183                                }
184                            });
185                        });
186                    });
187                } else {
188                    // 词尾无变化,直接尝试元音替换
189                    tryVowelSearch(word, word, function(results, candidate, isPais) {
190                        if (results && results.length > 0) {
191                            displayResults(results, word, candidate, isPais);
192                        } else {
193                            showNotFound(word);
194                        }
195                    });
196                }
197            });
198        });
199    }
200
201    /** 元音替换查询:依次尝试 a/e/i/o 其他元音,每个候选先查word再查pais */
202    function tryVowelSearch(original, baseWord, callback) {
203        var candidates = getVowelReplacements(baseWord);
204        if (candidates.length === 0) { callback(null, null, false); return; }
205        var index = 0;
206
207        function tryNext() {
208            if (index >= candidates.length) { callback(null, null, false); return; }
209            var candidate = candidates[index++];
210
211            // 先查 word
212            doExactSearchWord(candidate, function(wordResults) {
213                if (wordResults && wordResults.length > 0) {
214                    callback(wordResults, candidate, false);
215                    return;
216                }
217                // 再查 pais
218                doExactSearchPais(candidate, function(paisResults) {
219                    if (paisResults && paisResults.length > 0) {
220                        callback(paisResults, candidate, true);
221                        return;
222                    }
223                    tryNext();
224                });
225            });
226        }
227        tryNext();
228    }
229
230    function doExactSearchWord(term, callback) {
231        fetch('http://' + window.location.host + '/exactSearchWord/' + encodeURIComponent(term))
232            .then(function(r) { return r.json(); })
233            .then(function(data) {
234                callback(data && data.length > 0 ? data : null);
235            })
236            .catch(function() { callback(null); });
237    }
238
239    function doExactSearchPais(term, callback) {
240        fetch('http://' + window.location.host + '/exactSearchPais/' + encodeURIComponent(term))
241            .then(function(r) { return r.json(); })
242            .then(function(data) {
243                callback(data && data.length > 0 ? data : null);
244            })
245            .catch(function() { callback(null); });
246    }
247
248    function showNotFound(word) {
249        title.textContent = '查询: ' + word;
250        content.innerHTML = '<div style="color:#c00;padding:20px;text-align:center;">未找到: <b>' + escapeHtml(word) + '</b></div>';
251    }
252
253    // ==================== 显示结果(支持多结果顺序罗列)====================
254    function displayResults(items, originalWord, processedWord, isPaisMatch) {
255        if (!items || items.length === 0) {
256            showNotFound(originalWord);
257            return;
258        }
259
260        if (processedWord && processedWord !== originalWord.toLowerCase()) {
261            title.textContent = '查询: ' + originalWord + ' → ' + processedWord + (isPaisMatch ? ' (pais)' : '');
262        } else {
263            title.textContent = '查询: ' + originalWord + (isPaisMatch ? ' (pais)' : '');
264        }
265
266        var html = '';
267        items.forEach(function(item, idx) {
268            if (idx > 0) {
269                html += '<div style="border-top:1px solid #ddd;margin:8px 0;"></div>';
270            }
271            html += '<div style="margin-bottom:8px;">';
272            html += '<div style="font-size:18px;font-weight:bold;color:#006400;margin-bottom:4px;">' + escapeHtml(item.word) + '</div>';
273            html += '<div class="popupDefinition" style="padding-top:4px;">' + item.definition + '</div>';
274            html += '</div>';
275        });
276
277        content.innerHTML = html;
278
279        setTimeout(function() {
280            processPopupAudioLinks();
281            if (typeof window.addAiSpeakButtons === 'function') window.addAiSpeakButtons();
282            if (isPaisMatch) {
283                scrollToHighlightInPopup();
284            }
285        }, 80);
286    }
287
288    /** 只精确滚动到粉红高亮目标(支持多结果) */
289    function scrollToHighlightInPopup() {
290        var defDivs = content.querySelectorAll('.popupDefinition');
291        if (!defDivs.length) return;
292
293        for (var i = 0; i < defDivs.length; i++) {
294            var highlight = defDivs[i].querySelector('span[style*="#FF00FF"], span[style*="FF00FF"], b[style*="#FF00FF"], strong[style*="#FF00FF"]');
295            if (highlight) {
296                highlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
297                var originalBg = highlight.style.backgroundColor;
298                highlight.style.backgroundColor = 'rgba(255,0,255,0.2)';
299                highlight.style.transition = 'background-color 0.5s';
300                (function(h, bg) {
301                    setTimeout(function() {
302                        h.style.backgroundColor = bg || 'transparent';
303                    }, 1500);
304                })(highlight, originalBg);
305                break;
306            }
307        }
308    }
309
310    function processPopupAudioLinks() {
311        var links = content.querySelectorAll('a[href$=".mp3"], a[href$=".MP3"]');
312        links.forEach(function(link) {
313            if (link._popupAudioBound) return;
314            link._popupAudioBound = true;
315            link.addEventListener('click', function(e) {
316                e.preventDefault();
317                e.stopPropagation();
318                var href = this.getAttribute('href');
319                if (!href) return;
320                if (typeof window.playAudioWithoutControls === 'function') {
321                    window.playAudioWithoutControls(href);
322                } else if (typeof window.playAudioWithControls === 'function') {
323                    window.playAudioWithControls(href);
324                }
325            });
326        });
327    }
328
329    function showPopup(x, y) {
330        popup.style.display = 'block';
331        var rect = popup.getBoundingClientRect();
332        var winW = window.innerWidth, winH = window.innerHeight;
333        var left = x + 15, top = y + 15;
334        if (left + rect.width > winW - 10) left = x - rect.width - 15;
335        if (top + rect.height > winH - 10) top = y - rect.height - 15;
336        if (left < 10) left = 10;
337        if (top < 10) top = 10;
338        popup.style.left = left + 'px';
339        popup.style.top = top + 'px';
340    }
341
342    function escapeHtml(text) {
343        if (!text) return '';
344        var div = document.createElement('div');
345        div.textContent = text;
346        return div.innerHTML;
347    }
348
349    console.log('划词弹窗查询模块(word→pais 优先级 + 多结果罗列)已加载');
350})();

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.