Spaces:
Running
Running
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ (INSTANT)</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta name="theme-color" content="#7a0000" /> | |
| <style> | |
| :root{ | |
| --red:#b30000; --yellow:#ffd700; --dark-red:#7a0000; | |
| --bg:#fffaf2; --border:#e0c97f; --card:#ffffff; | |
| --muted:#666; --ink:#222; | |
| } | |
| *{ box-sizing:border-box; font-family:"Times New Roman", Georgia, serif; } | |
| body{ margin:0; background:var(--bg); color:var(--ink); } | |
| header{ | |
| background:linear-gradient(90deg,var(--red),var(--dark-red)); | |
| color:var(--yellow); padding:16px 24px; border-bottom:5px solid var(--yellow); | |
| } | |
| header h1{ margin:0; font-size:22px; text-transform:uppercase; letter-spacing:1px; } | |
| header small{ display:block; margin-top:6px; font-size:13px; color:#ffeaa7; } | |
| main{ padding:20px; max-width:1100px; margin:auto; } | |
| .panel{ background:var(--card); border:2px solid var(--border); padding:16px; margin-bottom:16px; } | |
| .hint{ | |
| background:#fff3c4; border:1px dashed #d6b657; padding:12px; line-height:1.55; font-size:14px; | |
| } | |
| .row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; } | |
| .searchBox{ position:relative; flex:1 1 520px; min-width:280px; } | |
| input[type="text"]{ | |
| width:100%; padding:12px; font-size:16px; | |
| border:2px solid var(--red); outline:none; background:#fff; | |
| } | |
| input[type="text"]:focus{ border-color:var(--dark-red); } | |
| select, button{ | |
| padding:10px 10px; font-size:14px; | |
| border:2px solid var(--border); background:#fff; cursor:pointer; | |
| } | |
| button.primary{ border-color:var(--red); } | |
| .stats{ font-size:14px; color:#555; margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; } | |
| /* Suggest dropdown */ | |
| .suggest{ | |
| position:absolute; left:0; right:0; top:100%; | |
| background:#fff; border:1px solid #ddd; z-index:50; | |
| max-height: 320px; overflow:auto; display:none; | |
| } | |
| .suggest.open{ display:block; } | |
| .suggest .item{ | |
| padding:10px 10px; border-bottom:1px dotted #ddd; | |
| display:flex; justify-content:space-between; gap:10px; | |
| cursor:pointer; | |
| } | |
| .suggest .item:hover{ background:#fff7dc; } | |
| .entry{ padding:12px 8px; border-bottom:1px dotted #ccc; line-height:1.65; } | |
| .entry:last-child{ border-bottom:none; } | |
| .word{ font-size:20px; font-weight:bold; color:var(--dark-red); display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; } | |
| .pos{ font-style:italic; color:var(--muted); font-size:14px; } | |
| .meaning{ margin-top:6px; padding-left:12px; } | |
| .meaning span{ display:block; margin-bottom:4px; } | |
| .highlight{ background:#fff2b2; font-weight:bold; } | |
| footer{ | |
| text-align:center; padding:12px; font-size:13px; color:#555; | |
| border-top:2px solid var(--border); margin-top:30px; | |
| } | |
| .kbd{ border:1px solid #bbb; background:#f7f7f7; padding:1px 6px; border-radius:4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ</h1> | |
| <small>Gõ là ra liền · Không dấu · Gợi ý · dev, 2026</small> | |
| </header> | |
| <main> | |
| <div class="panel hint"> | |
| <b>Gợi ý tra cứu (100 từ):</b> | |
| Chỉ cần gõ vào ô tìm kiếm, kết quả sẽ hiển thị ngay lập tức. Bạn có thể gõ <b>không dấu</b>: | |
| “an” sẽ khớp “ăn, ân, ắn…”. Danh sách <b>gợi ý</b> xuất hiện ngay dưới ô, bấm vào là tra nhanh. | |
| Dùng bộ lọc <b>Từ loại</b> để chỉ xem danh từ/động từ/tính từ… Khi từ khóa quá ngắn, hãy gõ thêm 1–2 ký tự để | |
| kết quả chính xác hơn. Nhấn <span class="kbd">Esc</span> để xoá nhanh, nhấn <span class="kbd">Enter</span> để “chốt” kết quả. | |
| Nếu bạn tìm theo nghĩa, cứ gõ cụm từ, hệ thống sẽ dò trong phần định nghĩa. | |
| </div> | |
| <div class="panel"> | |
| <div class="row"> | |
| <div class="searchBox"> | |
| <input id="q" type="text" placeholder="Nhập từ cần tra… (không dấu cũng được)" autocomplete="off" /> | |
| <div id="suggest" class="suggest" aria-label="Gợi ý"></div> | |
| </div> | |
| <select id="pos"> | |
| <option value="">Tất cả từ loại</option> | |
| <option value="d">Danh từ</option> | |
| <option value="đg">Động từ</option> | |
| <option value="t">Tính từ</option> | |
| <option value="tr">Trợ từ</option> | |
| <option value="c">Cảm từ</option> | |
| <option value="p">Phụ từ</option> | |
| <option value="k">Kết từ</option> | |
| <option value="đ">Đại từ</option> | |
| </select> | |
| <button class="primary" id="clear">Xoá</button> | |
| </div> | |
| <div class="stats" id="stats">Đang tải dữ liệu…</div> | |
| </div> | |
| <div class="panel" id="results"></div> | |
| </main> | |
| <footer> | |
| Dữ liệu: Từ điển tiếng Việt (Hoàng Phê) · JSON đặt cùng thư mục | |
| </footer> | |
| <script> | |
| /* ========================= | |
| 1) Normalize không dấu | |
| ========================= */ | |
| function normalizeVN(s) { | |
| return (s || "") | |
| .toLowerCase() | |
| .replace(/đ/g, "d") | |
| .normalize("NFD") | |
| .replace(/[\u0300-\u036f]/g, "") | |
| .replace(/[^a-z0-9\s\-]/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| function escapeHTML(str){ | |
| return (str ?? "").replace(/[&<>"']/g, m => ({ | |
| "&":"&","<":"<",">":">",'"':""","'":"'" | |
| }[m])); | |
| } | |
| function highlightText(text, qNorm){ | |
| const safe = escapeHTML(text); | |
| if(!qNorm) return safe; | |
| // highlight theo qNorm (không dấu) trên chính text hiển thị: nhẹ và đủ dùng | |
| const needle = qNorm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| if(!needle) return safe; | |
| const re = new RegExp("(" + needle + ")", "ig"); | |
| return safe.replace(re, '<span class="highlight">$1</span>'); | |
| } | |
| /* ========================= | |
| 2) Data + Index tối ưu | |
| ========================= */ | |
| const posLabel = { | |
| "d":"danh từ","đg":"động từ","t":"tính từ","tr":"trợ từ","c":"cảm từ","p":"phụ từ","k":"kết từ","đ":"đại từ" | |
| }; | |
| let ALL = []; // entries đã precompute | |
| let READY = false; | |
| const LIMIT_RESULTS = 350; // render tối đa | |
| const LIMIT_SUGGEST = 12; // gợi ý | |
| function prepEntry(e){ | |
| const tu = e.tu || ""; | |
| const nghiaArr = Array.isArray(e.nghia) ? e.nghia : []; | |
| const nghiaText = nghiaArr.join(" "); | |
| return { | |
| ...e, | |
| _tuNorm: normalizeVN(tu), | |
| _nghiaNorm: normalizeVN(nghiaText), | |
| _posText: e.tu_loai_day_du || posLabel[e.tu_loai] || e.tu_loai | |
| }; | |
| } | |
| /* ========================= | |
| 3) Search tức thì (xếp hạng) | |
| ========================= */ | |
| function searchInstant(qRaw, pos){ | |
| const qNorm = normalizeVN(qRaw); | |
| if(!qNorm){ | |
| // mặc định hiển thị một phần đầu để chứng minh đã load | |
| const base = pos ? ALL.filter(x => x.tu_loai === pos) : ALL; | |
| return { qNorm, list: base.slice(0, 200) }; | |
| } | |
| const pool = pos ? ALL.filter(x => x.tu_loai === pos) : ALL; | |
| const exact = []; | |
| const prefix = []; | |
| const contains = []; | |
| const meaning = []; | |
| for(const e of pool){ | |
| if(e._tuNorm === qNorm) exact.push(e); | |
| else if(e._tuNorm.startsWith(qNorm)) prefix.push(e); | |
| else if(e._tuNorm.includes(qNorm)) contains.push(e); | |
| else if(e._nghiaNorm.includes(qNorm)) meaning.push(e); | |
| // chặn sớm để luôn “ra liền” | |
| if(exact.length + prefix.length + contains.length >= LIMIT_RESULTS) break; | |
| } | |
| const merged = [...exact, ...prefix, ...contains, ...meaning]; | |
| // loại trùng | |
| const seen = new Set(); | |
| const out = []; | |
| for(const e of merged){ | |
| const key = e.tu + "||" + e.tu_loai + "||" + (e.nghia?.[0] || ""); | |
| if(seen.has(key)) continue; | |
| seen.add(key); | |
| out.push(e); | |
| if(out.length >= LIMIT_RESULTS) break; | |
| } | |
| return { qNorm, list: out }; | |
| } | |
| function buildSuggest(qRaw, pos){ | |
| const qNorm = normalizeVN(qRaw); | |
| if(!qNorm) return []; | |
| const pool = pos ? ALL.filter(x => x.tu_loai === pos) : ALL; | |
| const out = []; | |
| for(const e of pool){ | |
| if(e._tuNorm.startsWith(qNorm) || e._tuNorm === qNorm){ | |
| out.push(e); | |
| if(out.length >= LIMIT_SUGGEST) break; | |
| } | |
| } | |
| if(out.length < LIMIT_SUGGEST){ | |
| for(const e of pool){ | |
| if(e._tuNorm.includes(qNorm) && !out.includes(e)){ | |
| out.push(e); | |
| if(out.length >= LIMIT_SUGGEST) break; | |
| } | |
| } | |
| } | |
| return out; | |
| } | |
| /* ========================= | |
| 4) Render UI | |
| ========================= */ | |
| const $ = (id) => document.getElementById(id); | |
| const input = $("q"); | |
| const posSel = $("pos"); | |
| const resultsBox = $("results"); | |
| const statsBox = $("stats"); | |
| const suggestBox = $("suggest"); | |
| const btnClear = $("clear"); | |
| function renderResults(list, qNorm){ | |
| resultsBox.innerHTML = ""; | |
| if(!list.length){ | |
| resultsBox.innerHTML = `<div class="entry"><span class="muted">Không tìm thấy.</span></div>`; | |
| return; | |
| } | |
| for(const item of list){ | |
| const div = document.createElement("div"); | |
| div.className = "entry"; | |
| const wordHtml = highlightText(item.tu || "", qNorm); | |
| let html = ` | |
| <div class="word">${wordHtml} <span class="pos">(${escapeHTML(item._posText || "")})</span></div> | |
| <div class="meaning"> | |
| `; | |
| const nghia = Array.isArray(item.nghia) ? item.nghia : []; | |
| for(let i=0;i<nghia.length;i++){ | |
| html += `<span>${i+1}. ${highlightText(nghia[i], qNorm)}</span>`; | |
| } | |
| html += `</div>`; | |
| div.innerHTML = html; | |
| resultsBox.appendChild(div); | |
| } | |
| } | |
| function renderSuggest(items){ | |
| suggestBox.innerHTML = ""; | |
| if(!items.length){ | |
| suggestBox.classList.remove("open"); | |
| return; | |
| } | |
| for(const it of items){ | |
| const row = document.createElement("div"); | |
| row.className = "item"; | |
| row.innerHTML = ` | |
| <div><b>${escapeHTML(it.tu || "")}</b> <span class="muted">(${escapeHTML(it._posText || "")})</span></div> | |
| <div class="muted">↵</div> | |
| `; | |
| row.addEventListener("mousedown", (ev) => { | |
| ev.preventDefault(); | |
| input.value = it.tu || ""; | |
| run(true); | |
| suggestBox.classList.remove("open"); | |
| }); | |
| suggestBox.appendChild(row); | |
| } | |
| suggestBox.classList.add("open"); | |
| } | |
| /* ========================= | |
| 5) Run: “gõ là ra liền” | |
| - requestAnimationFrame để mượt | |
| - không debounce dài (chỉ 1 frame) | |
| ========================= */ | |
| let raf = 0; | |
| function run(fromPick=false){ | |
| if(!READY) return; | |
| const qRaw = input.value || ""; | |
| const pos = posSel.value || ""; | |
| const { qNorm, list } = searchInstant(qRaw, pos); | |
| statsBox.textContent = | |
| `Kết quả: ${list.length.toLocaleString("vi-VN")} · Tổng: ${ALL.length.toLocaleString("vi-VN")}` + | |
| (pos ? ` · Lọc: ${posLabel[pos] || pos}` : "") + | |
| (qRaw ? " · (hiển thị tức thì)" : ""); | |
| renderResults(list, qNorm); | |
| if(!fromPick){ | |
| const sug = buildSuggest(qRaw, pos); | |
| renderSuggest(sug); | |
| } | |
| } | |
| function runNextFrame(){ | |
| cancelAnimationFrame(raf); | |
| raf = requestAnimationFrame(() => run(false)); | |
| } | |
| /* ========================= | |
| 6) Events | |
| ========================= */ | |
| input.addEventListener("input", () => runNextFrame()); | |
| posSel.addEventListener("change", () => run(true)); | |
| btnClear.addEventListener("click", () => { | |
| input.value = ""; | |
| suggestBox.classList.remove("open"); | |
| run(true); | |
| input.focus(); | |
| }); | |
| document.addEventListener("click", (e) => { | |
| if(!suggestBox.contains(e.target) && e.target !== input){ | |
| suggestBox.classList.remove("open"); | |
| } | |
| }); | |
| input.addEventListener("keydown", (e) => { | |
| if(e.key === "Escape"){ | |
| input.value = ""; | |
| suggestBox.classList.remove("open"); | |
| run(true); | |
| } | |
| if(e.key === "Enter"){ | |
| suggestBox.classList.remove("open"); | |
| run(true); | |
| } | |
| }); | |
| /* ========================= | |
| 7) Load JSON (cùng thư mục) | |
| ========================= */ | |
| (async function boot(){ | |
| try{ | |
| statsBox.textContent = "Đang tải dữ liệu…"; | |
| const res = await fetch("./tu_dien_hoang_phe_clean.json", { cache: "no-cache" }); | |
| const data = await res.json(); | |
| const muc = Array.isArray(data.muc_tu) ? data.muc_tu : []; | |
| ALL = muc.map(prepEntry); | |
| READY = true; | |
| statsBox.textContent = `Đã tải: ${ALL.length.toLocaleString("vi-VN")} mục từ · Gõ để tra ngay`; | |
| run(true); | |
| input.focus(); | |
| }catch(err){ | |
| console.error(err); | |
| statsBox.textContent = "❌ Không load được JSON. Kiểm tra file cùng thư mục và quyền truy cập."; | |
| resultsBox.innerHTML = `<div class="entry"><span class="muted">Không tải được dữ liệu.</span></div>`; | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |