www.git

www

~ Personal site — projects, posts, about. Built with sqzass.
git clone https://github.com/debuman/www.git

static/js/search.js (7224B)

      1 /* 검색 팔레트. 의존성 없음.
      2  *
      3  * sqzass 가 언어마다 색인을 하나씩 낸다. 각 행은 페이지 하나이고
      4  * `t` 제목, `d` 설명, `u` URL, `s` 갈래 이름, `c` 본문 텍스트다. `d` 와 `s` 는
      5  * 비어 있으면 아예 없다 — 그래서 읽을 때마다 있는지 확인한다.
      6  *
      7  * 단어가 아니라 부분 문자열로 찾는다. 한국어는 명사에 조사가 붙고 띄어쓰기
      8  * 없이 합성어를 만들기 때문에, 단어 색인으로는 `검색엔진최적화` 안의 `최적화` 를
      9  * 영영 찾지 못한다.
     10  *
     11  * 다이얼로그는 <dialog> 다. Escape, 백드롭, 포커스 트랩, 닫을 때 트리거로
     12  * 포커스 되돌리기가 전부 플랫폼 동작이라 우리가 쓸 코드가 아니다.
     13  */
     14 (function () {
     15   "use strict";
     16 
     17   var trigger = document.querySelector(".search-trigger");
     18   var dialog = document.getElementById("search-dialog");
     19   if (!trigger || !dialog || !dialog.showModal) return;
     20 
     21   var input = dialog.querySelector(".palette-input");
     22   var list = dialog.querySelector(".palette-results");
     23   var status = dialog.querySelector(".palette-status");
     24   var script = document.querySelector("script[data-empty]");
     25   var EMPTY = (script && script.dataset.empty) || "No matches.";
     26   var HINT = (script && script.dataset.hint) || "";
     27 
     28   var rows = null;
     29   var loading = false;
     30   var cursor = -1;
     31 
     32   /* 단축키 힌트는 페이지의 성질이 아니라 읽는 사람 키보드의 성질이라 여기서 쓴다.
     33      템플릿이 넣어 둔 값은 JS 가 없을 때 남는 것. */
     34   var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
     35   var kbd = trigger.querySelector("kbd");
     36   if (kbd) kbd.textContent = mac ? "⌘ /" : "Ctrl /";
     37 
     38   function load() {
     39     if (rows || loading) return Promise.resolve();
     40     loading = true;
     41     status.textContent = "…";
     42     return fetch(trigger.dataset.index)
     43       .then(function (r) { return r.json(); })
     44       .then(function (data) { rows = data; status.textContent = HINT; })
     45       .catch(function () { status.textContent = EMPTY; })
     46       .then(function () { loading = false; });
     47   }
     48 
     49   function open() {
     50     load().then(function () { render(input.value); });
     51     if (!dialog.open) dialog.showModal();
     52     input.focus();
     53     input.select();
     54   }
     55 
     56   /* 제목에 걸린 것이 설명보다, 설명이 본문보다 위다. 제목이 그 말로 시작하면
     57      한 번 더 올라간다 — 찾던 것을 정확히 친 경우다. */
     58   function score(row, terms) {
     59     var t = row.t.toLowerCase();
     60     var d = (row.d || "").toLowerCase();
     61     var c = row.c.toLowerCase();
     62     var total = 0;
     63     for (var i = 0; i < terms.length; i++) {
     64       var q = terms[i];
     65       if (t.indexOf(q) === 0) total += 100;
     66       else if (t.indexOf(q) > -1) total += 60;
     67       else if (d.indexOf(q) > -1) total += 25;
     68       else if (c.indexOf(q) > -1) total += 8;
     69       else return -1; /* 모든 낱말이 어딘가에는 있어야 한다. OR 이 아니라 AND. */
     70     }
     71     return total;
     72   }
     73 
     74   function mark(text, terms) {
     75     var frag = document.createDocumentFragment();
     76     var lower = text.toLowerCase();
     77     var at = -1, len = 0;
     78     for (var i = 0; i < terms.length; i++) {
     79       var p = lower.indexOf(terms[i]);
     80       if (p > -1 && (at === -1 || p < at)) { at = p; len = terms[i].length; }
     81     }
     82     if (at === -1) { frag.appendChild(document.createTextNode(text)); return frag; }
     83     frag.appendChild(document.createTextNode(text.slice(0, at)));
     84     var m = document.createElement("mark");
     85     m.textContent = text.slice(at, at + len);
     86     frag.appendChild(m);
     87     frag.appendChild(document.createTextNode(text.slice(at + len)));
     88     return frag;
     89   }
     90 
     91   function render(query) {
     92     list.textContent = "";
     93     cursor = -1;
     94     var q = query.trim().toLowerCase();
     95     if (!rows || !q) { status.textContent = rows ? HINT : status.textContent; return; }
     96 
     97     var terms = q.split(/\s+/);
     98     var hits = [];
     99     for (var i = 0; i < rows.length; i++) {
    100       var s = score(rows[i], terms);
    101       if (s > -1) hits.push({ row: rows[i], score: s });
    102     }
    103     /* 같은 점수면 제목순. 순서가 매번 달라지면 두 번째 검색에서 눈이 길을 잃는다. */
    104     hits.sort(function (a, b) { return b.score - a.score || a.row.t.localeCompare(b.row.t); });
    105     hits = hits.slice(0, 12);
    106 
    107     if (!hits.length) { status.textContent = EMPTY; return; }
    108     status.textContent = String(hits.length);
    109 
    110     hits.forEach(function (h) {
    111       var li = document.createElement("li");
    112       var a = document.createElement("a");
    113       a.href = h.row.u;
    114 
    115       var title = document.createElement("span");
    116       title.className = "palette-title";
    117       title.appendChild(mark(h.row.t, terms));
    118       a.appendChild(title);
    119 
    120       var sub = h.row.s || h.row.d;
    121       if (sub) {
    122         var crumb = document.createElement("span");
    123         crumb.className = "palette-crumb";
    124         crumb.textContent = sub;
    125         a.appendChild(crumb);
    126       }
    127 
    128       li.appendChild(a);
    129       list.appendChild(li);
    130     });
    131   }
    132 
    133   function move(step) {
    134     var items = list.children;
    135     if (!items.length) return;
    136     if (cursor > -1) items[cursor].removeAttribute("aria-selected");
    137     cursor = (cursor + step + items.length) % items.length;
    138     items[cursor].setAttribute("aria-selected", "true");
    139     items[cursor].scrollIntoView({ block: "nearest" });
    140   }
    141 
    142   trigger.addEventListener("click", open);
    143 
    144   /* Ctrl/⌘ + /. e.code 가 아니라 e.key 로 잡는다 — 슬래시에 수정자가 필요한
    145      레이아웃에서는 물리 키가 딴 데 있고, 중요한 건 읽는 사람이 무엇을 쳤는가다.
    146      preventDefault 는 Firefox 가 맨 / 를 빠른 검색에 묶어 두기 때문이다. */
    147   document.addEventListener("keydown", function (e) {
    148     if (!(e.ctrlKey || e.metaKey) || e.key !== "/") return;
    149     /* 글을 치고 있는 중이면 끼어들지 않는다. */
    150     var el = document.activeElement;
    151     if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) {
    152       if (el !== input) return;
    153     }
    154     e.preventDefault();
    155     dialog.open ? dialog.close() : open();
    156   });
    157 
    158   input.addEventListener("input", function () { render(input.value); });
    159 
    160   input.addEventListener("keydown", function (e) {
    161     if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
    162     else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
    163     else if (e.key === "Enter") {
    164       /* 폼 안이라 Enter 는 기본적으로 다이얼로그를 닫는다. 고른 것이 있으면
    165          닫는 대신 거기로 간다. */
    166       var pick = cursor > -1 ? list.children[cursor] : list.children[0];
    167       if (pick) { e.preventDefault(); pick.querySelector("a").click(); }
    168     }
    169   });
    170 
    171   /* 닫힐 때 비워 둔다. 다시 열었을 때 지난 검색 결과가 남아 있으면, 방금
    172      친 것에 대한 답으로 읽힌다. */
    173   dialog.addEventListener("close", function () {
    174     input.value = "";
    175     list.textContent = "";
    176     status.textContent = "";
    177     cursor = -1;
    178   });
    179 
    180   /* 백드롭 클릭. <dialog> 는 백드롭도 자기 자신을 target 으로 준다. */
    181   dialog.addEventListener("click", function (e) {
    182     if (e.target === dialog) dialog.close();
    183   });
    184 })();