PageSourceSearch

https://iamtaolong.github.io/assets/js/minimap.js

js iamtaolong.github.io collected 2026-10-03 09:19:26 UTC 5,884 bytes, 150 lines download raw bytes

1/* Page minimap: floating table of contents with scroll tracking (desktop only). */
2(function () {
3  function make(tag, cls, text) {
4    const n = document.createElement(tag);
5    if (cls) n.className = cls;
6    if (text != null) n.textContent = text;
7    return n;
8  }
9
10  function storage(key, value) {
11    try {
12      if (value === undefined) return localStorage.getItem(key);
13      localStorage.setItem(key, value);
14    } catch (e) { return null; }
15  }
16
17  window.buildMinimap = function (entries, opts) {
18    opts = opts || {};
19    entries = entries.filter(e => e && e.el);
20    if (!entries.length || document.querySelector('.mm')) return;
21    const offset = opts.offset || 72;
22
23    const root = make('nav', 'mm');
24    if (opts.top) root.style.setProperty('--mm-top', opts.top);
25    if (opts.accent) root.style.setProperty('--mm-accent', opts.accent);
26    root.setAttribute('aria-label', 'Page minimap');
27    const head = make('div', 'mm-head');
28    head.appendChild(make('span', 'mm-title', opts.title || 'on this page'));
29    const toggle = make('button', 'mm-toggle');
30    toggle.type = 'button';
31    head.appendChild(toggle);
32    root.appendChild(head);
33
34    const body = make('div', 'mm-body');
35    const rail = make('div', 'mm-rail-track');
36    const fill = make('span', 'mm-rail-fill');
37    rail.appendChild(fill);
38    body.appendChild(rail);
39    const list = make('ol', 'mm-list');
40    body.appendChild(list);
41    root.appendChild(body);
42
43    const flat = [];
44    let locked = null;
45    function addLink(parentList, entry, level, parent) {
46      const li = make('li', 'mm-item mm-level-' + level);
47      const a = make('a', 'mm-link');
48      a.href = '#';
49      a.title = entry.label;
50      a.appendChild(make('span', 'mm-dot'));
51      a.appendChild(make('span', 'mm-label', entry.label));
52      li.appendChild(a);
53      parentList.appendChild(li);
54      const rec = { a, li, el: entry.el, parent };
55      flat.push(rec);
56      a.addEventListener('click', ev => {
57        ev.preventDefault();
58        const details = entry.el.closest && entry.el.closest('details');
59        if (details && !details.open) details.open = true;
60        const y = entry.el.getBoundingClientRect().top + window.scrollY - offset;
61        locked = rec;
62        setActive(rec);
63        window.scrollTo({ top: y, behavior: 'smooth' });
64      });
65      if (entry.children && entry.children.length) {
66        const sub = make('ol', 'mm-sub');
67        li.appendChild(sub);
68        entry.children.filter(c => c && c.el).forEach(c => addLink(sub, c, level + 1, rec));
69      }
70    }
71    entries.forEach(e => addLink(list, e, 0, null));
72    document.body.appendChild(root);
73
74    function setCollapsed(c) {
75      root.classList.toggle('mm-collapsed', c);
76      toggle.textContent = c ? '+' : '–';
77      toggle.setAttribute('aria-label', c ? 'Show minimap' : 'Hide minimap');
78      toggle.setAttribute('aria-expanded', String(!c));
79      storage('mm-collapsed', c ? '1' : '0');
80    }
81    toggle.addEventListener('click', () => setCollapsed(!root.classList.contains('mm-collapsed')));
82    setCollapsed(storage('mm-collapsed') === '1');
83
84    function fit() {
85      const ref = opts.contentEl && opts.contentEl();
86      if (!ref) return;
87      const free = (window.innerWidth - ref.getBoundingClientRect().width) / 2;
88      root.classList.toggle('mm-slim', free < 226);
89    }
90
91    let current = null;
92    function setActive(active) {
93      if (active !== current) {
94        flat.forEach(r => r.li.classList.remove('mm-active', 'mm-active-parent'));
95        active.li.classList.add('mm-active');
96        for (let p = active.parent; p; p = p.parent) p.li.classList.add('mm-active-parent');
97        current = active;
98        const lt = active.li.offsetTop, lh = active.li.offsetHeight;
99        if (lt < list.scrollTop || lt + lh > list.scrollTop + list.clientHeight) {
100          list.scrollTo({ top: lt - list.clientHeight / 3, behavior: 'smooth' });
101        }
102      }
103    }
104    function spy() {
105      if (!locked) {
106        const line = offset + 48;
107        let active = null;
108        for (const r of flat) {
109          if (r.el.offsetParent === null && r.el.tagName !== 'DETAILS') continue;
110          if (r.el.getBoundingClientRect().top <= line) active = r;
111        }
112        setActive(active || flat[0]);
113      }
114      const max = document.documentElement.scrollHeight - window.innerHeight;
115      fill.style.height = (max > 0 ? Math.min(1, window.scrollY / max) * 100 : 0) + '%';
116    }
117    let ticking = false;
118    const onScroll = () => {
119      if (ticking) return;
120      ticking = true;
121      requestAnimationFrame(() => { ticking = false; spy(); });
122    };
123    window.addEventListener('scroll', onScroll, { passive: true });
124    const unlock = () => { if (locked) { locked = null; onScroll(); } };
125    ['wheel', 'touchstart', 'keydown', 'mousedown'].forEach(t => window.addEventListener(t, e => {
126      if (!root.contains(e.target)) unlock();
127    }, { passive: true }));
128    window.addEventListener('resize', () => { fit(); spy(); });
129    fit();
130    spy();
131  };
132
133  /* Project pages: numbered <section>s with figure blocks underneath. */
134  window.minimapFromSections = function () {
135    const clean = t => t.replace(/\s+/g, ' ').replace(/^Fig\.\s*[—-]\s*/i, '').trim();
136    const entries = [];
137    document.querySelectorAll('main > section[id]').forEach(sec => {
138      if (sec.id === 'home') return;
139      const h2 = sec.querySelector('h2');
140      if (!h2) return;
141      const children = [];
142      sec.querySelectorAll('.figure-block').forEach(b => {
143        const lab = b.querySelector('.figure-label');
144        if (lab) children.push({ label: clean(lab.textContent), el: b });
145      });
146      entries.push({ label: clean(h2.textContent), el: sec, children });
147    });
148    buildMinimap(entries, { offset: 76, contentEl: () => document.querySelector('main > section:not(#home) .container') });
149  };
150})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.