www.git

www

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

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