forums.tonybtw.com

https://git.tonybtw.com/forums.tonybtw.com.git git://git.tonybtw.com/forums.tonybtw.com.git
8,894 bytes raw
1
'use strict';
2
3
/**
4
 * this is the telescope-style fuzzy finder modal. it owns the overlay, the
5
 * result list + preview pane, and the current selection. the pure helpers
6
 * (clamp_index, result_url, is_editable) hang off the class as static methods
7
 */
8
export class Telescope {
9
10
    /**
11
     * this function wraps an index into [0, len), so moving past either end
12
     * of the result list loops around
13
     *
14
     * @param {number} i
15
     * @param {number} len
16
     * @return {number}
17
     */
18
    static clamp_index(i, len) {
19
        if (len <= 0) {
20
            return 0;
21
        }
22
        if (i < 0) {
23
            return len - 1;
24
        }
25
        if (i >= len) {
26
            return 0;
27
        }
28
        return i;
29
    }
30
31
    /**
32
     * this function builds the thread url for a search result
33
     *
34
     * @param {object} result
35
     * @return {string}
36
     */
37
    static result_url(result) {
38
        return '/thread/' + encodeURIComponent(result.thread_id);
39
    }
40
41
    /**
42
     * this function tells whether an element is something youre typing into,
43
     * so the "/" shortcut doesnt hijack a real input
44
     *
45
     * @param {Element} el
46
     * @return {boolean}
47
     */
48
    static is_editable(el) {
49
        if (!el) {
50
            return false;
51
        }
52
        const tag = el.tagName;
53
        return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
54
    }
55
56
    constructor() {
57
        this.results = [];
58
        this.selected = 0;
59
        this.open = false;
60
        this.timer = null;
61
        this.controller = null;
62
        this.build();
63
    }
64
65
    /**
66
     * this function builds the modal dom once and wires up its input + click
67
     * handlers, then parks it on the body hidden
68
     *
69
     * @return {void}
70
     */
71
    build() {
72
        const overlay = document.createElement('div');
73
        overlay.className = 'tele-overlay';
74
        overlay.innerHTML =
75
            '<div class="tele">' +
76
            '<input class="tele-input" type="text" placeholder="Search threads and posts..." autocomplete="off" spellcheck="false">' +
77
            '<div class="tele-body"><ul class="tele-list"></ul><div class="tele-preview"></div></div>' +
78
            '<div class="tele-footer"><span>&#x2191;&#x2193; move</span><span>&#x23ce; open</span><span>esc close</span></div>' +
79
            '</div>';
80
81
        this.overlay = overlay;
82
        this.input = overlay.querySelector('.tele-input');
83
        this.list = overlay.querySelector('.tele-list');
84
        this.preview = overlay.querySelector('.tele-preview');
85
86
        overlay.addEventListener('mousedown', (e) => {
87
            if (e.target === overlay) {
88
                this.close();
89
            }
90
        });
91
        this.input.addEventListener('input', () => {
92
            this.query(this.input.value);
93
        });
94
        this.input.addEventListener('keydown', (e) => {
95
            this.on_key(e);
96
        });
97
98
        document.body.appendChild(overlay);
99
    }
100
101
    /**
102
     * this function opens the modal, clearing whatever was there last and
103
     * focusing the input
104
     *
105
     * @return {void}
106
     */
107
    show() {
108
        this.open = true;
109
        this.overlay.classList.add('tele-visible');
110
        this.input.value = '';
111
        this.results = [];
112
        this.selected = 0;
113
        this.render();
114
        this.input.focus();
115
    }
116
117
    /**
118
     * this function closes the modal and aborts any in-flight search
119
     *
120
     * @return {void}
121
     */
122
    close() {
123
        this.open = false;
124
        this.overlay.classList.remove('tele-visible');
125
        if (this.controller) {
126
            this.controller.abort();
127
            this.controller = null;
128
        }
129
    }
130
131
    /**
132
     * this function handles keys while the modal is focused: esc closes,
133
     * enter opens the selection, and arrows / ctrl-j / ctrl-k move the cursor
134
     *
135
     * @param {KeyboardEvent} e
136
     * @return {void}
137
     */
138
    on_key(e) {
139
        if (e.key === 'Escape') {
140
            e.preventDefault();
141
            this.close();
142
        } else if (e.key === 'Enter') {
143
            e.preventDefault();
144
            this.choose();
145
        } else if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'j')) {
146
            e.preventDefault();
147
            this.move(1);
148
        } else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
149
            e.preventDefault();
150
            this.move(-1);
151
        }
152
    }
153
154
    /**
155
     * this function moves the selection by delta (wrapping at the ends)
156
     *
157
     * @param {number} delta
158
     * @return {void}
159
     */
160
    move(delta) {
161
        this.selected = Telescope.clamp_index(this.selected + delta, this.results.length);
162
        this.render();
163
    }
164
165
    /**
166
     * this function navigates to the currently selected result
167
     *
168
     * @return {void}
169
     */
170
    choose() {
171
        const r = this.results[this.selected];
172
        if (r) {
173
            window.location.href = Telescope.result_url(r);
174
        }
175
    }
176
177
    /**
178
     * this function debounces typing so we only hit the server once the user
179
     * pauses for a moment
180
     *
181
     * @param {string} q
182
     * @return {void}
183
     */
184
    query(q) {
185
        if (this.timer) {
186
            clearTimeout(this.timer);
187
        }
188
        this.timer = setTimeout(() => this.fetch(q), 120);
189
    }
190
191
    /**
192
     * this function fetches results from the /search json endpoint,
193
     * cancelling any earlier request thats still in flight so stale results
194
     * cant land late
195
     *
196
     * @param {string} q
197
     * @return {void}
198
     */
199
    fetch(q) {
200
        if (q.trim() === '') {
201
            this.results = [];
202
            this.selected = 0;
203
            this.render();
204
            return;
205
        }
206
        if (this.controller) {
207
            this.controller.abort();
208
        }
209
        this.controller = new AbortController();
210
        window.fetch('/search?json=1&q=' + encodeURIComponent(q), { signal: this.controller.signal })
211
            .then((r) => r.json())
212
            .then((data) => {
213
                this.results = data || [];
214
                this.selected = 0;
215
                this.render();
216
            })
217
            .catch(() => {});
218
    }
219
220
    /**
221
     * this function repaints the result list and the preview pane for the
222
     * current selection. titles go in as text, but the snippet is set as html
223
     * since the server already escaped it and only left <mark> tags in
224
     *
225
     * @return {void}
226
     */
227
    render() {
228
        this.list.innerHTML = '';
229
230
        if (!this.results.length) {
231
            this.preview.innerHTML = '';
232
            const empty = document.createElement('li');
233
            empty.className = 'tele-empty';
234
            empty.textContent = this.input.value.trim() === '' ? 'Type to search' : 'No results';
235
            this.list.appendChild(empty);
236
            return;
237
        }
238
239
        this.results.forEach((r, i) => {
240
            const li = document.createElement('li');
241
            li.className = 'tele-item' + (i === this.selected ? ' tele-active' : '');
242
            const title = document.createElement('span');
243
            title.className = 'tele-item-title';
244
            title.textContent = r.title;
245
            li.appendChild(title);
246
            if (r.board_slug) {
247
                const board = document.createElement('span');
248
                board.className = 'tele-item-board';
249
                board.textContent = r.board_slug;
250
                li.appendChild(board);
251
            }
252
            li.addEventListener('mouseenter', () => {
253
                this.selected = i;
254
                this.render();
255
            });
256
            li.addEventListener('click', () => this.choose());
257
            this.list.appendChild(li);
258
        });
259
260
        const sel = this.results[this.selected];
261
        this.preview.innerHTML =
262
            '<div class="tele-prev-title"></div><div class="tele-prev-snippet"></div>';
263
        this.preview.querySelector('.tele-prev-title').textContent = sel.title;
264
        this.preview.querySelector('.tele-prev-snippet').innerHTML = sel.snippet || '';
265
    }
266
}
267
268
/**
269
 * this function wires up the global "/" and ctrl-k shortcuts (ignored while
270
 * youre typing in a field) plus any [data-telescope] links
271
 *
272
 * @return {void}
273
 */
274
function telescope_init() {
275
    const tele = new Telescope();
276
    document.addEventListener('keydown', (e) => {
277
        if (tele.open) {
278
            return;
279
        }
280
        if (e.key === '/' && !Telescope.is_editable(e.target)) {
281
            e.preventDefault();
282
            tele.show();
283
        } else if (e.ctrlKey && e.key === 'k') {
284
            e.preventDefault();
285
            tele.show();
286
        }
287
    });
288
    const links = document.querySelectorAll('[data-telescope]');
289
    links.forEach((a) => {
290
        a.addEventListener('click', (e) => {
291
            e.preventDefault();
292
            tele.show();
293
        });
294
    });
295
}
296
297
if (typeof document !== 'undefined' && document.querySelectorAll) {
298
    telescope_init();
299
}