Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

static/js/structured.js

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 9.3 KB · 273 lines raw

1 /* Structured-file reading aids for the blob view: fold blocks, search keys
2 and values, filter the file down to what matched.
3
4 Everything here is progressive enhancement. The server renders a plain
5 numbered table with structure hung off data attributes (see
6 internal/modules/structured.go); this file builds the toolbar and the fold
7 controls at runtime, so a page loaded without JS is exactly the table it
8 has always been rather than a dead search box. */
9 (function () {
10 var src = document.querySelector('.source.structured');
11 if (!src) return;
12 var rows = Array.prototype.slice.call(src.querySelectorAll('tbody > tr'));
13 if (!rows.length) return;
14
15 // One record per line. Text is read once from textContent -- never
16 // innerHTML, which would match highlight markup as if it were content.
17 // The alias links and anchor-use badges are chrome the server added, not
18 // file content -- searching "uses" must not match every anchor.
19 function cellText(cell) {
20 if (!cell) return '';
21 var s = '';
22 for (var i = 0; i < cell.childNodes.length; i++) {
23 var c = cell.childNodes[i];
24 if (c.nodeType === 1 && (c.className === 'aref' || c.className === 'anchor-uses')) continue;
25 s += c.textContent;
26 }
27 return s;
28 }
29
30 var R = rows.map(function (tr) {
31 var cell = tr.querySelector('.lc');
32 var text = cellText(cell);
33 var key = tr.dataset.key || '';
34 var val = text;
35 if (key) {
36 var at = text.indexOf(key);
37 var colon = text.indexOf(':', at < 0 ? 0 : at + key.length);
38 if (colon >= 0) val = text.slice(colon + 1);
39 }
40 return {
41 tr: tr, cell: cell,
42 n: +tr.dataset.n,
43 depth: +tr.dataset.depth || 0,
44 foldTo: +tr.dataset.foldTo || 0,
45 key: key.toLowerCase(),
46 text: text.toLowerCase(),
47 blank: text.trim() === '',
48 val: val.trim().toLowerCase(),
49 match: false
50 };
51 });
52 var byLine = {};
53 R.forEach(function (r) { byLine[r.n] = r; });
54
55 var collapsed = {}; // opener line number -> true
56 var scope = 'all'; // all | keys | values
57 var filtering = false;
58 var matches = [];
59 var cursor = -1;
60
61 /* ---- fold controls ------------------------------------------------ */
62
63 // Minified or tab-indented files have no indentation to fold on. They get
64 // search, but no gutter reserved for chevrons that will never appear and
65 // no collapse-all that would do nothing.
66 var foldable = R.some(function (r) { return r.foldTo > 0; });
67 if (foldable) src.classList.add('foldable');
68
69 R.forEach(function (r) {
70 if (!r.foldTo || !r.cell) return;
71 var b = document.createElement('button');
72 b.className = 'fold';
73 b.type = 'button';
74 b.setAttribute('aria-expanded', 'true');
75 b.title = 'Collapse lines ' + (r.n + 1) + '–' + r.foldTo;
76 b.addEventListener('click', function () {
77 if (collapsed[r.n]) delete collapsed[r.n]; else collapsed[r.n] = true;
78 render();
79 });
80 r.tr.querySelector('.ln').appendChild(b);
81 // On the cell, not the row: attr() in ::after only reads the attributes
82 // of the element the pseudo-element belongs to.
83 r.cell.dataset.foldNote = '⋯ ' + (r.foldTo - r.n) + ' lines';
84 });
85
86 function foldAll(on) {
87 collapsed = {};
88 if (on) R.forEach(function (r) { if (r.foldTo) collapsed[r.n] = true; });
89 render();
90 }
91
92 // A match hidden inside a collapsed block reads as "no results", so open
93 // every block enclosing it before rendering.
94 function expose(n) {
95 for (var o in collapsed) {
96 var r = byLine[o];
97 if (r && r.n < n && n <= r.foldTo) delete collapsed[o];
98 }
99 }
100
101 /* ---- search ------------------------------------------------------- */
102
103 function hay(r) {
104 return scope === 'keys' ? r.key : scope === 'values' ? r.val : r.text;
105 }
106
107 var lastQ = '';
108 function search(q) {
109 q = lastQ = q.trim().toLowerCase();
110 matches = [];
111 R.forEach(function (r) {
112 r.match = q !== '' && hay(r).indexOf(q) >= 0;
113 if (r.match) matches.push(r);
114 });
115 matches.forEach(function (r) { expose(r.n); });
116 cursor = matches.length ? 0 : -1;
117 render();
118 }
119
120 // In filter mode a matching line keeps the ancestors that give it its path
121 // and the block it owns -- a bare list of hits out of context is worse
122 // than the file.
123 function keepSet() {
124 var keep = {}, stack = [];
125 R.forEach(function (r) {
126 // A blank line carries no indentation, so it is not a level -- letting
127 // one truncate the stack would strip the parents off every match
128 // below a blank line inside a nested block.
129 if (r.blank) return;
130 stack.length = Math.min(stack.length, r.depth);
131 stack[r.depth] = r;
132 if (!r.match) return;
133 stack.forEach(function (a) { if (a) keep[a.n] = true; });
134 for (var i = r.n; i <= r.foldTo; i++) keep[i] = true;
135 });
136 return keep;
137 }
138
139 /* ---- render ------------------------------------------------------- */
140
141 function render() {
142 if (filtering && !matches.length) {
143 filtering = false;
144 filterBtn.setAttribute('aria-pressed', 'false');
145 }
146 var keep = filtering ? keepSet() : null;
147 var hideUntil = 0;
148 R.forEach(function (r) {
149 var hidden = r.n <= hideUntil || (keep !== null && !keep[r.n]);
150 if (collapsed[r.n]) hideUntil = Math.max(hideUntil, r.foldTo);
151 r.tr.classList.toggle('is-hidden', hidden);
152 r.tr.classList.toggle('is-collapsed', !!collapsed[r.n]);
153 r.tr.classList.toggle('is-match', r.match);
154 r.tr.classList.remove('is-current');
155 var b = r.tr.querySelector('.fold');
156 if (b) b.setAttribute('aria-expanded', collapsed[r.n] ? 'false' : 'true');
157 });
158 if (cursor >= 0 && matches[cursor]) matches[cursor].tr.classList.add('is-current');
159 count.textContent = matches.length
160 ? (cursor + 1) + ' / ' + matches.length
161 : (input.value.trim() ? 'no matches' : '');
162 filterBtn.disabled = !matches.length;
163 }
164
165 function step(d) {
166 if (!matches.length) return;
167 cursor = (cursor + d + matches.length) % matches.length;
168 render();
169 matches[cursor].tr.scrollIntoView({ block: 'center' });
170 }
171
172 /* ---- toolbar ------------------------------------------------------ */
173
174 var bar = document.createElement('div');
175 bar.className = 'sbar';
176
177 var input = document.createElement('input');
178 input.type = 'search';
179 input.className = 'sbar-q';
180 input.placeholder = 'Search keys and values… (/)';
181 input.setAttribute('aria-label', 'Search this file');
182
183 var scopes = document.createElement('div');
184 scopes.className = 'sbar-scope';
185 ['all', 'keys', 'values'].forEach(function (s) {
186 var b = document.createElement('button');
187 b.type = 'button';
188 b.textContent = s;
189 b.setAttribute('aria-pressed', String(s === scope));
190 b.addEventListener('click', function () {
191 scope = s;
192 Array.prototype.forEach.call(scopes.children, function (c) {
193 c.setAttribute('aria-pressed', String(c === b));
194 });
195 search(input.value);
196 });
197 scopes.appendChild(b);
198 });
199
200 var count = document.createElement('span');
201 count.className = 'sbar-count';
202
203 function chip(label, title, fn) {
204 var b = document.createElement('button');
205 b.type = 'button';
206 b.className = 'sbar-btn';
207 b.textContent = label;
208 b.title = title;
209 b.addEventListener('click', fn);
210 return b;
211 }
212
213 var prev = chip('↑', 'Previous match (Shift+Enter)', function () { step(-1); });
214 var next = chip('↓', 'Next match (Enter)', function () { step(1); });
215
216 var filterBtn = chip('filter', 'Show only matching lines and their parents', function () {
217 filtering = !filtering;
218 filterBtn.setAttribute('aria-pressed', String(filtering));
219 render();
220 });
221 filterBtn.setAttribute('aria-pressed', 'false');
222 filterBtn.disabled = true;
223
224 var allCollapsed = false;
225 var foldBtn = chip('collapse all', 'Fold every block', function () {
226 allCollapsed = !allCollapsed;
227 foldBtn.textContent = allCollapsed ? 'expand all' : 'collapse all';
228 foldAll(allCollapsed);
229 });
230
231 bar.appendChild(input);
232 bar.appendChild(scopes);
233 bar.appendChild(count);
234 bar.appendChild(prev);
235 bar.appendChild(next);
236 bar.appendChild(filterBtn);
237 if (foldable) bar.appendChild(foldBtn);
238 src.parentNode.insertBefore(bar, src);
239
240 var timer;
241 input.addEventListener('input', function () {
242 clearTimeout(timer);
243 timer = setTimeout(function () { search(input.value); }, 90);
244 });
245 input.addEventListener('keydown', function (e) {
246 if (e.key === 'Enter') {
247 e.preventDefault();
248 clearTimeout(timer);
249 // Enter on a query the debounce hasn't run yet searches; Enter on the
250 // same query walks the results.
251 if (input.value.trim().toLowerCase() !== lastQ) search(input.value);
252 else step(e.shiftKey ? -1 : 1);
253 }
254 else if (e.key === 'Escape') { input.value = ''; search(''); input.blur(); }
255 });
256 document.addEventListener('keydown', function (e) {
257 if (e.key !== '/' || e.target === input || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
258 e.preventDefault();
259 input.focus();
260 input.select();
261 });
262
263 // Deep links (#L42, and the alias jump links) must survive folding.
264 window.addEventListener('hashchange', function () {
265 var m = /^#L(\d+)$/.exec(location.hash);
266 if (!m) return;
267 expose(+m[1]);
268 if (filtering) { filtering = false; filterBtn.setAttribute('aria-pressed', 'false'); }
269 render();
270 });
271
272 render();
273 })();