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 })();