static/js/ui.js (13389B)
1 /* 테마 토글, 스크롤 리빌, 코드 블록 복사.
2 *
3 * 셋 다 없어도 페이지는 읽힌다. 리빌을 숨기는 CSS 가 `.js` 뒤에 있고 그
4 * 클래스는 <head> 인라인 스크립트가 붙이므로, 이 파일이 못 와도 내용이
5 * 사라지지 않는다.
6 */
7 (function () {
8 "use strict";
9
10 var root = document.documentElement;
11 var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
12
13 /* ---------- theme ---------- */
14
15 var toggle = document.querySelector(".theme-toggle");
16 if (toggle) {
17 toggle.addEventListener("click", function () {
18 var next = root.dataset.theme === "light" ? "dark" : "light";
19 root.dataset.theme = next;
20 try { localStorage.setItem("theme", next); } catch (e) {}
21 });
22 }
23
24 /* ---------- reveal ---------- */
25
26 /* 한 번 보이면 다시 숨기지 않는다. 위로 스크롤할 때 도로 사라지는 것은
27 읽던 자리로 돌아가는 사람에게 고장으로 보인다. */
28 var targets = document.querySelectorAll("[data-reveal]");
29 if (reduced || !("IntersectionObserver" in window)) {
30 targets.forEach(function (el) { el.classList.add("is-lit"); });
31 } else {
32 var io = new IntersectionObserver(function (entries) {
33 entries.forEach(function (e) {
34 if (!e.isIntersecting) return;
35 e.target.classList.add("is-lit");
36 io.unobserve(e.target);
37 });
38 }, { rootMargin: "0px 0px -12% 0px", threshold: 0.05 });
39
40 targets.forEach(function (el) {
41 /* 이미 첫 화면에 들어와 있는 것은 관찰을 기다리지 않는다 — 관찰자가
42 첫 콜백을 내기까지의 한 프레임이 눈에 띈다. */
43 if (el.getBoundingClientRect().top < window.innerHeight * 0.9) {
44 el.classList.add("is-lit");
45 } else {
46 io.observe(el);
47 }
48 });
49 }
50
51 /* ---------- copy ---------- */
52
53 var script = document.querySelector("script[data-copy]");
54 var LABEL = (script && script.dataset.copy) || "Copy";
55 var DONE = (script && script.dataset.copied) || "Copied";
56
57 if (navigator.clipboard) {
58 document.querySelectorAll(".prose pre").forEach(function (pre) {
59 var btn = document.createElement("button");
60 btn.type = "button";
61 btn.className = "copy-btn";
62 btn.textContent = LABEL;
63
64 btn.addEventListener("click", function () {
65 /* 버튼 자신의 글자가 pre 안에 있으므로 code 만 읽는다. 안 그러면
66 복사한 명령 끝에 "Copy" 가 붙어 나간다. */
67 var code = pre.querySelector("code") || pre;
68 navigator.clipboard.writeText(code.innerText).then(function () {
69 btn.textContent = DONE;
70 btn.classList.add("is-done");
71 setTimeout(function () {
72 btn.textContent = LABEL;
73 btn.classList.remove("is-done");
74 }, 1600);
75 });
76 });
77
78 pre.appendChild(btn);
79 });
80 }
81
82 /* ---------- feature preview ---------- */
83
84 /* 홈 맨 위 글의 미리보기. 손이 닿거나 포커스가 오면 돌고, 떠나면 첫 프레임으로
85 되감는다 — 그 프레임이 곧 poster 라 되돌아간 자리가 원래 자리다.
86
87 영상은 preload="none" 이라 여기서 play() 를 부르기 전에는 한 바이트도 받지
88 않는다. 첫 화면에 700KB 를 얹지 않으려는 것이고, 그래서 이 파일이 못 와도
89 남는 것은 정지 그림 한 장이다.
90
91 동작을 줄이라는 설정이면 아무것도 걸지 않는다. 이 사이트에서 저절로 움직이는
92 것은 이것 하나뿐이라, 끄라고 한 사람에게는 없는 것과 같아야 한다. */
93 var feature = document.querySelector(".feature");
94 var clip = feature && feature.querySelector(".feature-media");
95 if (clip && !reduced) {
96 var play = function () {
97 var p = clip.play();
98 /* 자동재생이 막히면 거절된 프로미스가 콘솔에 빨간 줄을 남긴다. 미리보기가
99 안 도는 것은 고장이 아니라 브라우저의 정책이므로 조용히 삼킨다. */
100 if (p && p.catch) p.catch(function () {});
101 };
102 var stop = function () {
103 clip.pause();
104 clip.currentTime = 0;
105 };
106 feature.addEventListener("mouseenter", play);
107 feature.addEventListener("mouseleave", stop);
108 /* 키보드로 온 사람도 같은 것을 본다. 미리보기가 링크 하나에 붙어 있으므로
109 포커스가 곧 "지금 이걸 보고 있다"는 뜻이다. */
110 feature.addEventListener("focus", play);
111 feature.addEventListener("blur", stop);
112 }
113
114 /* ---------- reading rail ---------- */
115
116 /* 넓은 화면에서 오른쪽 가장자리에 서는 목차. 항목마다 세로 헤어라인 한 칸이고,
117 칸의 높이가 그 절이 글에서 차지하는 몫이다. 항목이 다 같은 크기인 목록은
118 "가운데 절이 이 글의 절반"이라는 사실을 말하지 못한다.
119
120 자료는 본문에 이미 있는 .toc 다. 이 파일이 못 오면 그 목록이 그대로 남는다 —
121 목록을 감추는 CSS 가 여기서 붙이는 .has-rail 뒤에 있다.
122
123 첫 칸은 제목에서 첫 소제목까지다. 그게 없을 때 재 보니 이 글의 34% 가 어느
124 칸에도 안 들어갔고, 처음 750px 을 굴리는 동안 레일이 통째로 죽어 있었다.
125 도착해서 읽기 시작한 사람에게 아무 반응이 없는 진행 표시는 고장으로 보인다.
126
127 넓은 화면인지는 여기서 묻지 않는다. 그 판단은 main.css 의 min-width 하나가
128 갖고 있다. 두 곳에 두면 두 값이 조용히 어긋나고, 그때 나오는 증상은 "레일이
129 보이는데 안 찬다"라 숫자 문제로 보이지 않는다.
130
131 이른 반환이 여러 번 필요해서 안쪽 IIFE 다. */
132 (function () {
133 /* 읽는 줄. 마스트헤드에 가리지 않은 높이의 위에서 30%. */
134 var LINE = 0.3;
135 /* 이만큼도 못 구르는 문서에는 진행 표시를 그리지 않는다. */
136 var FLOOR = 240;
137
138 var toc = document.querySelector(".toc");
139 var prose = document.querySelector(".prose");
140 var title = document.querySelector(".page-title");
141 if (!toc || !prose) return;
142 if (root.scrollHeight - window.innerHeight < FLOOR) return;
143
144 /* 목차의 링크를 전부 쓴다. 깊이별로 고르지 않는다 — 레일이 그리는 것은
145 개요가 아니라 분량이고, 분량에는 깊이가 없다. 구조는 좁은 화면의 목록이
146 들여쓰기로 이미 말한다. */
147 var heads = [];
148 toc.querySelectorAll("a[href^='#']").forEach(function (a) {
149 var href = a.getAttribute("href");
150 /* 한국어 제목은 id 가 한글이다(#택하지-않은-우회로). "#"+id 를
151 querySelector 에 넘기면 CSS 선택자로 파싱돼 던진다. 소스에 적힌 값을
152 그대로 쓰므로 디코딩도 필요 없다. */
153 var el = document.getElementById(href.slice(1));
154 if (el) heads.push({ el: el, href: href, text: a.textContent });
155 });
156 /* 소제목이 하나뿐인 목차는 목차가 아니라 링크 하나다. */
157 if (heads.length < 2) return;
158
159 /* 첫 칸은 머리말이다. 제목을 이름으로 쓰고 <main id="content"> 로 간다 —
160 건너뛰기 링크가 이미 쓰는 진짜 앵커라, 가짜 "#" 도 클릭 핸들러도 필요
161 없다. */
162 var parts = [{ el: prose, href: "#content", text: (title && title.textContent) || "" }]
163 .concat(heads);
164
165 var host = document.createElement("nav");
166 host.className = "rail";
167 /* 이름은 목록이 이미 갖고 있다. JS 안에 문자열을 두면 번역도 안 되고
168 tools/fonts.py 의 눈에도 안 띈다. */
169 host.setAttribute("aria-label", toc.getAttribute("aria-label") || "");
170
171 var rows = parts.map(function (s) {
172 /* 격자가 두 열이라 넣는 순서대로 이름·눈금이 한 행이 된다. 이름이 앞이고
173 눈금이 뒤다 — 화면에서 이름이 안쪽, 눈금이 바깥쪽이다. */
174 var label = document.createElement("a");
175 label.className = "rail-label";
176 label.setAttribute("href", s.href);
177 label.textContent = s.text;
178
179 var seg = document.createElement("span");
180 seg.className = "rail-seg";
181 var fill = document.createElement("i");
182 fill.className = "rail-fill";
183 seg.appendChild(fill);
184
185 host.appendChild(label);
186 host.appendChild(seg);
187 return { fill: fill, label: label, el: s.el, f: -1 };
188 });
189
190 /* 목차 바로 뒤에 꽂는다. body 끝에 붙이면 화면에서는 본문 옆이지만 탭
191 순서에서는 본문 뒤가 되고, 다 읽은 뒤에야 닿는 목차는 아무도 안 쓴다. */
192 toc.insertAdjacentElement("afterend", host);
193 root.classList.add("has-rail");
194
195 var tops = [];
196 var lead = 0;
197 var at = -1;
198
199 function absTop(el) { return el.getBoundingClientRect().top + window.scrollY; }
200
201 /* 재는 곳은 여기 하나다. 스크롤 중에는 아무것도 재지 않고 산수만 한다. */
202 function layout() {
203 /* 읽는 줄이 마스트헤드에 가리는 만큼을 제목 자신의 scroll-margin-top 에서
204 가져온다. main.css 가 앵커 점프를 위해 이미 선언해 둔 값이고, 브라우저가
205 눌러서 뛸 때 쓰는 바로 그 값이다 — 한 군데서 나오므로 "여기 있다"와
206 "여기로 간다"가 어긋날 수 없다. */
207 var pad = parseFloat(getComputedStyle(heads[0].el).scrollMarginTop) || 0;
208 lead = pad + (window.innerHeight - pad) * LINE;
209
210 tops = [];
211 var prev = 0;
212 rows.forEach(function (r) {
213 /* 앞 경계보다 위로는 못 간다. 서브픽셀이나 sticky 때문에 뒤 제목이 앞
214 제목보다 위로 재어지면 높이가 음수인 칸이 생긴다. */
215 prev = Math.max(absTop(r.el), prev);
216 tops.push(prev);
217 });
218 /* 읽기는 본문이 끝나는 데서 끝난다. 페이저와 콜로폰은 글이 아니다. */
219 tops.push(Math.max(absTop(prose) + prose.offsetHeight, prev + 1));
220
221 /* 칸의 높이가 그 절의 몫이다. 아래끝을 두면 아주 짧은 절도 사라지지 않고
222 두 줄짜리 이름이 옆 칸을 침범하지도 않는다.
223 ponytail: 절이 열 개를 넘으면(768px 높이 화면) 아래끝의 합이 상자보다
224 커져 넘치고, 그 앞에서 이미 아래끝이 몫을 눌러 두 칸의 순서를 뒤집을 수
225 있다. 그때 손댈 곳은 2.2rem 과 min(64vh, 34rem) 둘이다. */
226 var total = tops[tops.length - 1] - tops[0];
227 var track = "";
228 for (var i = 0; i < rows.length; i++) {
229 track += " minmax(2.2rem, " +
230 ((tops[i + 1] - tops[i]) / total * 100).toFixed(3) + "fr)";
231 }
232 host.style.gridTemplateRows = track.slice(1);
233 progress();
234 }
235
236 function progress() {
237 if (!tops.length) return;
238 var line = window.scrollY + lead;
239 /* 문서 끝에 닿으면 마지막 절은 끝난 것이다. 읽는 줄은 화면 아래 한 뼘을
240 영영 못 지나가므로, 이게 없으면 마지막 칸은 절대 안 찬다. */
241 var done = window.scrollY + window.innerHeight >= root.scrollHeight - 2;
242 /* 머리말 칸이 있으므로 첫 화면부터 현재가 있다. 도착한 사람은 실제로
243 머리말을 읽고 있다. */
244 var now = 0;
245
246 for (var i = 0; i < rows.length; i++) {
247 var f = done ? 1 : (line - tops[i]) / Math.max(1, tops[i + 1] - tops[i]);
248 f = f < 0 ? 0 : f > 1 ? 1 : f;
249 /* 값이 그대로면 손대지 않는다. 스크롤 한 번에 절 수만큼 쓰는 자리다. */
250 if (rows[i].f !== f) {
251 rows[i].fill.style.height = (f * 100).toFixed(2) + "%";
252 rows[i].f = f;
253 }
254 /* 화면 상자가 아니라 줄 하나로 고른다. 제목 여럿이 같이 보일 때 누가
255 이기는지 따질 일이 없다 — 줄 위에 있는 마지막 절이다. 관찰자를 쓰지
256 않으므로 부드러운 스크롤과 싸울 일도 없다. */
257 if (line >= tops[i]) now = i;
258 }
259 if (done) now = rows.length - 1;
260
261 if (now === at) return;
262 /* 눈금에는 표시를 따로 안 단다. 지금 절은 이 열에서 유일하게 덜 찬 칸이고,
263 그게 이미 표시다. 이름에는 aria-current 가 붙는다 — 보조기술이 읽는 것도
264 그것이고, 색은 거기서 나온다. */
265 if (at >= 0) rows[at].label.removeAttribute("aria-current");
266 rows[now].label.setAttribute("aria-current", "true");
267 at = now;
268 }
269
270 var ticking = false;
271 window.addEventListener("scroll", function () {
272 if (ticking) return;
273 ticking = true;
274 requestAnimationFrame(function () { ticking = false; progress(); });
275 }, { passive: true });
276
277 var timer;
278 window.addEventListener("resize", function () {
279 clearTimeout(timer);
280 timer = setTimeout(layout, 120);
281 });
282
283 /* 한글 서브셋은 unicode-range 뒤에 있어서 첫 페인트 뒤에 온다. 오면 본문
284 높이가 바뀌고, 그 전에 잰 경계는 전부 틀린 값이 된다. */
285 window.addEventListener("load", layout);
286 if ("ResizeObserver" in window) new ResizeObserver(layout).observe(prose);
287
288 layout();
289 })();
290 })();