UNPKG

5.72 kBJavaScriptView Raw
1/*
2 * Documentative
3 * (c) 2020 dragonwocky <thedragonring.bod@gmail.com>
4 * (https://dragonwocky.me/) under the MIT license
5 */
6
7class Scrollnav {
8 constructor(menu, content, options) {
9 if (!(menu instanceof HTMLElement))
10 throw Error('scrollnav: invalid <menu> element provided');
11 if (!(content instanceof HTMLElement))
12 throw Error('scrollnav: invalid <content> element provided');
13 if (typeof options !== 'object') options = {};
14
15 if (Scrollnav.prototype.INITIATED)
16 throw Error('scrollnav: only 1 instance per page allowed!');
17 Scrollnav.prototype.INITIATED = true;
18
19 this.ID;
20 this.ticking = [];
21 this._menu = menu;
22 this._content = content;
23 this._links = [];
24 this._sections = [...this._menu.querySelectorAll('ul li a')].reduce(
25 (list, link) => {
26 if (!link.getAttribute('href').startsWith('#')) return list;
27 let section = this._content.querySelector(link.getAttribute('href'));
28 if (!section) return list;
29
30 this._links.push(link);
31 link.onclick = async ev => {
32 ev.preventDefault();
33 const ID = link.getAttribute('href');
34 this.highlightHeading(ID);
35 this.scrollContent(ID);
36 this.setHash(ID);
37 };
38
39 return [...list, section];
40 },
41 []
42 );
43 this._topheading = `#${this._sections[0].id}`;
44
45 window.onhashchange = this.watchHash.bind(this);
46 this._content.addEventListener('scroll', ev => {
47 if (!this.ticking.length) {
48 this.ticking.push(1);
49 requestAnimationFrame(() => {
50 this.watchScroll(ev);
51 this.ticking.pop();
52 });
53 }
54 });
55
56 this.set(null, false);
57 return this;
58 }
59
60 set(ID, smooth) {
61 this.highlightHeading(ID);
62 this.scrollMenu(ID, smooth);
63 this.scrollContent(ID, smooth);
64 this.setHash(ID);
65 }
66
67 parseID(ID) {
68 if (!ID || typeof ID !== 'string') ID = location.hash || this._topheading;
69 if (!ID.startsWith('#')) ID = `#${ID}`;
70 if (!this._links.find(el => el.getAttribute('href') === ID))
71 ID = this._topheading;
72 this.ID = ID;
73 return ID;
74 }
75 highlightHeading(ID) {
76 this.parseID(ID);
77 this._links.forEach(el =>
78 el.getAttribute('href') === this.ID
79 ? el.classList.add('active')
80 : el.classList.remove('active')
81 );
82 return true;
83 }
84
85 watchHash(ev) {
86 ev.preventDefault();
87 if (ev.newURL !== ev.oldURL) {
88 this.set();
89 }
90 }
91 setHash(ID) {
92 if (!history.replaceState) return false;
93 this.parseID(ID);
94 history.replaceState(null, null, ID === this._topheading ? '#' : this.ID);
95 return true;
96 }
97
98 scrollContent(ID, smooth = true) {
99 this.ticking.push(1);
100 this.parseID(ID);
101 let offset = this._sections.find(el => `#${el.id}` === this.ID).offsetTop;
102 if (offset < this._content.clientHeight / 2) offset = 0;
103 this._content.scroll({
104 top: offset,
105 behavior: smooth ? 'smooth' : 'auto'
106 });
107 setTimeout(() => this.ticking.pop(), 1000);
108 return true;
109 }
110 scrollMenu(ID, smooth = true) {
111 this.parseID(ID);
112 let offset = this._links.find(el => el.getAttribute('href') === this.ID)
113 .offsetTop;
114 if (offset < this._menu.clientHeight / 2) offset = 0;
115 this._menu.scroll({
116 top: offset,
117 behavior: smooth ? 'smooth' : 'auto'
118 });
119 return true;
120 }
121 watchScroll(ev) {
122 const viewport = this._content.clientHeight,
123 ID = this._sections.reduce(
124 (carry, el) => {
125 const rect = el.getBoundingClientRect(),
126 height = rect.bottom - rect.top,
127 visible = {
128 top: rect.top >= 0 && rect.top < viewport,
129 bottom: rect.bottom > 0 && rect.top < viewport
130 };
131 let pixels = 0;
132 if (visible.top && visible.bottom) {
133 pixels = height; // whole el
134 } else if (visible.top) {
135 pixels = viewport - rect.top;
136 } else if (visible.bottom) {
137 pixels = rect.bottom;
138 } else if (height > viewport && rect.top < 0) {
139 const absolute = Math.abs(rect.top);
140 if (absolute < height) pixels = height - absolute; // part of el
141 }
142 pixels = (pixels / height) * 100;
143 return pixels > carry[0] ? [pixels, el] : carry;
144 },
145 [0, null]
146 )[1].id;
147 this.ID = ID;
148 this.scrollMenu(this.ID);
149 clearTimeout(this.afterScroll);
150 this.afterScroll = setTimeout(
151 () => void (this.highlightHeading(this.ID) && this.setHash(this.ID)),
152 100
153 );
154 }
155}
156
157let constructed = false;
158const construct = () => {
159 if (document.readyState !== 'complete' || constructed) return false;
160 constructed = true;
161
162 if (
163 location.pathname.endsWith('index.html') &&
164 window.location.protocol === 'https:'
165 )
166 location.replace('./' + location.hash);
167
168 new Scrollnav(
169 document.querySelector('aside'),
170 document.querySelector('.documentative')
171 );
172
173 document.querySelector('.toggle button').onclick = () =>
174 document.body.classList.toggle('mobilemenu');
175
176 if (window.matchMedia) {
177 let prev;
178 const links = [...document.head.querySelectorAll('link[rel*="icon"]')],
179 pointer = document.createElement('link');
180 pointer.setAttribute('rel', 'icon');
181 document.head.appendChild(pointer);
182 setInterval(() => {
183 const match = links.find(link => window.matchMedia(link.media).matches);
184 if (!match || match.media === prev) return;
185 prev = match.media;
186 pointer.setAttribute('href', match.getAttribute('href'));
187 }, 500);
188 links.forEach(link => document.head.removeChild(link));
189 }
190};
191
192construct();
193document.addEventListener('readystatechange', construct);