Switch to unified view

a b/docs/assets/termynal/termynal.js
1
/**
2
 * termynal.js
3
 * A lightweight, modern and extensible animated terminal window, using
4
 * async/await.
5
 *
6
 * @author Ines Montani <ines@ines.io>
7
 * @version 0.0.1
8
 * @license MIT
9
 *
10
 * Modified version from https://github.com/tiangolo/typer
11
 *
12
 */
13
14
'use strict';
15
16
/** Generate a terminal widget. */
17
class Termynal {
18
    /**
19
     * Construct the widget's settings.
20
     * @param {(string|Node)=} container - Query selector or container element.
21
     * @param {Object=} options - Custom settings.
22
     * @param {string} options.prefix - Prefix to use for data attributes.
23
     * @param {number} options.startDelay - Delay before animation, in ms.
24
     * @param {number} options.typeDelay - Delay between each typed character, in ms.
25
     * @param {number} options.lineDelay - Delay between each line, in ms.
26
     * @param {number} options.progressLength - Number of characters displayed as progress bar.
27
     * @param {string} options.progressChar – Character to use for progress bar, defaults to █.
28
     * @param {number} options.progressPercent - Max percent of progress.
29
     * @param {string} options.cursor – Character to use for cursor, defaults to ▋.
30
     * @param {Object[]} lineData - Dynamically loaded line data objects.
31
     * @param {boolean} options.noInit - Don't initialise the animation.
32
     */
33
    constructor(container = '#termynal', options = {}) {
34
        this.container = (typeof container === 'string') ? document.querySelector(container) : container;
35
        this.pfx = `data-${options.prefix || 'ty'}`;
36
        this.originalStartDelay = this.startDelay = options.startDelay
37
            || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600;
38
        this.originalTypeDelay = this.typeDelay = options.typeDelay
39
            || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 50;
40
        this.originalLineDelay = this.lineDelay = options.lineDelay
41
            || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 500;
42
        this.progressLength = options.progressLength
43
            || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40;
44
        this.progressChar = options.progressChar
45
            || this.container.getAttribute(`${this.pfx}-progressChar`) || '█';
46
        this.progressPercent = options.progressPercent
47
            || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100;
48
        this.cursor = options.cursor
49
            || this.container.getAttribute(`${this.pfx}-cursor`) || '▋';
50
        this.lineData = this.lineDataToElements(options.lineData || []);
51
        this.loadLines()
52
        if (!options.noInit) this.init()
53
    }
54
55
    loadLines() {
56
        // Load all the lines and create the container so that the size is fixed
57
        // Otherwise it would be changing and the user viewport would be constantly
58
        // moving as she/he scrolls
59
        const finish = this.generateFinish()
60
        finish.style.visibility = 'hidden'
61
        this.container.appendChild(finish)
62
        // Appends dynamically loaded lines to existing line elements.
63
        this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData);
64
        for (let line of this.lines) {
65
            line.style.visibility = 'hidden'
66
            this.container.appendChild(line)
67
        }
68
        const restart = this.generateRestart()
69
        restart.style.visibility = 'hidden'
70
        this.container.appendChild(restart)
71
        this.container.setAttribute('data-termynal', '');
72
    }
73
74
    /**
75
     * Initialise the widget, get lines, clear container and start animation.
76
     */
77
    init() {
78
        /**
79
         * Calculates width and height of Termynal container.
80
         * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS.
81
         */
82
        const containerStyle = getComputedStyle(this.container);
83
        this.container.style.width = containerStyle.width !== '0px' ?
84
            containerStyle.width : undefined;
85
        this.container.style.minHeight = containerStyle.height !== '0px' ?
86
            containerStyle.height : undefined;
87
88
        this.container.setAttribute('data-termynal', '');
89
        this.container.innerHTML = '';
90
        for (let line of this.lines) {
91
            line.style.visibility = 'visible'
92
        }
93
        this.start();
94
    }
95
96
97
    /**
98
     * Start the animation and rener the lines depending on their data attributes.
99
     */
100
    async start() {
101
        this.addCopy()
102
        this.addFinish()
103
        await this._wait(this.startDelay);
104
105
        for (let line of this.lines) {
106
            const type = line.getAttribute(this.pfx);
107
            const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay;
108
109
            if (type == 'input') {
110
                line.setAttribute(`${this.pfx}-cursor`, this.cursor);
111
                await this.type(line);
112
                await this._wait(delay);
113
            }
114
115
            else if (type == 'progress') {
116
                await this.progress(line);
117
                await this._wait(delay);
118
            }
119
120
            else {
121
                this.container.appendChild(line);
122
                await this._wait(delay);
123
            }
124
125
            line.removeAttribute(`${this.pfx}-cursor`);
126
        }
127
        this.addRestart()
128
        this.finishElement.style.visibility = 'hidden'
129
        this.lineDelay = this.originalLineDelay
130
        this.typeDelay = this.originalTypeDelay
131
        this.startDelay = this.originalStartDelay
132
    }
133
134
    generateRestart() {
135
        const restart = document.createElement('a')
136
        restart.onclick = (e) => {
137
            e.preventDefault()
138
            this.container.innerHTML = ''
139
            this.init()
140
        }
141
        restart.href = '#'
142
        restart.setAttribute('data-terminal-control', '')
143
        restart.innerHTML = "restart ↻"
144
        return restart
145
    }
146
147
    generateCopy() {
148
        var dialog = document.getElementsByClassName('md-dialog')[0]
149
        var dialog_text = document.getElementsByClassName('md-dialog__inner md-typeset')[0]
150
        const copy = document.createElement('a')
151
        copy.classList.add("md-clipboard")
152
        copy.classList.add("md-icon")
153
        copy.onclick = (e) => {
154
            e.preventDefault()
155
            var command = ''
156
            for (let line of this.lines) {
157
                if (line.getAttribute("data-ty") == 'input') {
158
                    command = command + line.innerHTML + '\n'
159
                }
160
            }
161
            navigator.clipboard.writeText(command)
162
            dialog.setAttribute('data-md-state', 'open');
163
            dialog_text.innerText = 'Copied to clipboard';
164
165
            setTimeout(function () {
166
                dialog.removeAttribute('data-md-state');
167
            }, 2000);
168
        }
169
        copy.setAttribute('data-terminal-copy', '')
170
        return copy
171
    }
172
173
    generateFinish() {
174
        const finish = document.createElement('a')
175
        finish.onclick = (e) => {
176
            e.preventDefault()
177
            this.lineDelay = 0
178
            this.typeDelay = 0
179
            this.startDelay = 0
180
        }
181
        finish.href = '#'
182
        finish.setAttribute('data-terminal-control', '')
183
        finish.innerHTML = "fast →"
184
        this.finishElement = finish
185
        return finish
186
    }
187
188
    addRestart() {
189
        const restart = this.generateRestart()
190
        this.container.appendChild(restart)
191
    }
192
193
    addFinish() {
194
        const finish = this.generateFinish()
195
        this.container.appendChild(finish)
196
    }
197
198
    addCopy() {
199
        let copy = this.generateCopy()
200
        this.container.appendChild(copy)
201
    }
202
203
    /**
204
     * Animate a typed line.
205
     * @param {Node} line - The line element to render.
206
     */
207
    async type(line) {
208
        const chars = [...line.textContent];
209
        line.textContent = '';
210
        this.container.appendChild(line);
211
212
        for (let char of chars) {
213
            const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay;
214
            await this._wait(delay);
215
            line.textContent += char;
216
        }
217
    }
218
219
    /**
220
     * Animate a progress bar.
221
     * @param {Node} line - The line element to render.
222
     */
223
    async progress(line) {
224
        const progressLength = line.getAttribute(`${this.pfx}-progressLength`)
225
            || this.progressLength;
226
        const progressChar = line.getAttribute(`${this.pfx}-progressChar`)
227
            || this.progressChar;
228
        const chars = progressChar.repeat(progressLength);
229
        const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`)
230
            || this.progressPercent;
231
        line.textContent = '';
232
        this.container.appendChild(line);
233
234
        for (let i = 1; i < chars.length + 1; i++) {
235
            await this._wait(this.typeDelay) / 4;
236
            const percent = Math.round(i / chars.length * 100);
237
            line.textContent = `${chars.slice(0, i)} ${percent}%`;
238
            if (percent > progressPercent) {
239
                break;
240
            }
241
        }
242
    }
243
244
    /**
245
     * Helper function for animation delays, called with `await`.
246
     * @param {number} time - Timeout, in ms.
247
     */
248
    _wait(time) {
249
        return new Promise(resolve => setTimeout(resolve, time));
250
    }
251
252
    /**
253
     * Converts line data objects into line elements.
254
     *
255
     * @param {Object[]} lineData - Dynamically loaded lines.
256
     * @param {Object} line - Line data object.
257
     * @returns {Element[]} - Array of line elements.
258
     */
259
    lineDataToElements(lineData) {
260
        return lineData.map(line => {
261
            let div = document.createElement('div');
262
            div.innerHTML = `<span ${this._attributes(line)}>${line.value || ''}</span>`;
263
264
            return div.firstElementChild;
265
        });
266
    }
267
268
    /**
269
     * Helper function for generating attributes string.
270
     *
271
     * @param {Object} line - Line data object.
272
     * @returns {string} - String of attributes.
273
     */
274
    _attributes(line) {
275
        let attrs = '';
276
        for (let prop in line) {
277
            // Custom add class
278
            if (prop === 'class') {
279
                attrs += ` class=${line[prop]} `
280
                continue
281
            }
282
            if (prop === 'type') {
283
                attrs += `${this.pfx}="${line[prop]}" `
284
            } else if (prop !== 'value') {
285
                attrs += `${this.pfx}-${prop}="${line[prop]}" `
286
            }
287
        }
288
289
        return attrs;
290
    }
291
}
292
293
/**
294
* HTML API: If current script has container(s) specified, initialise Termynal.
295
*/
296
if (document.currentScript.hasAttribute('data-termynal-container')) {
297
    const containers = document.currentScript.getAttribute('data-termynal-container');
298
    containers.split('|')
299
        .forEach(container => new Termynal(container))
300
}
301
302
document.querySelectorAll(".use-termynal").forEach(node => {
303
    node.style.display = "block";
304
    new Termynal(node, {
305
        lineDelay: 500
306
    });
307
});
308
const progressLiteralStart = "---> 100%";
309
const promptLiteralStart = "$ ";
310
const customPromptLiteralStart = "$* ";
311
const commentPromptLiteralStart = "# ";
312
const colorOutputLiteralStart = "color:";
313
const termynalActivateClass = "termy";
314
let termynals = [];
315
316
function createTermynals() {
317
    document
318
        .querySelectorAll(`.${termynalActivateClass} .highlight`)
319
        .forEach(node => {
320
            const text = node.textContent;
321
            const lines = text.split("\n");
322
            const useLines = [];
323
            let buffer = [];
324
            function saveBuffer() {
325
                if (buffer.length) {
326
                    let isBlankSpace = true;
327
                    buffer.forEach(line => {
328
                        if (line) {
329
                            isBlankSpace = false;
330
                        }
331
                    });
332
                    var dataValue = {};
333
                    if (isBlankSpace) {
334
                        dataValue["delay"] = 0;
335
                    }
336
                    if (buffer[buffer.length - 1] === "") {
337
                        // A last single <br> won't have effect
338
                        // so put an additional one
339
                        buffer.push("");
340
                    }
341
342
                    const bufferValue = buffer.join("<br>");
343
                    dataValue["value"] = bufferValue;
344
                    useLines.push(dataValue);
345
                    buffer = [];
346
                }
347
            }
348
            for (let line of lines) {
349
                if (line === progressLiteralStart) {
350
                    saveBuffer();
351
                    useLines.push({
352
                        type: "progress"
353
                    });
354
                } else if (line.startsWith(promptLiteralStart)) {
355
                    saveBuffer();
356
                    const value = line.replace(promptLiteralStart, "").trimEnd();
357
                    useLines.push({
358
                        type: "input",
359
                        value: value
360
                    });
361
                } else if (line.startsWith(commentPromptLiteralStart)) {
362
                    saveBuffer();
363
                    const value = "💬 " + line.replace(commentPromptLiteralStart, "").trimEnd();
364
                    const color_value = "<span style=' color: grey;'>" + value + "</span>"
365
                    useLines.push({
366
                        value: color_value,
367
                        class: "termynal-comment",
368
                        delay: 0
369
                    });
370
                } else if (line.startsWith(customPromptLiteralStart)) {
371
                    saveBuffer();
372
                    const prompt = line.slice(3, line.indexOf(' ', 3))
373
                    let value = line.slice(line.indexOf(' ', 3)).trimEnd();
374
                    useLines.push({
375
                        type: "input",
376
                        value: value,
377
                        prompt: prompt
378
                    });
379
                } else if (line.startsWith(colorOutputLiteralStart)) {
380
                    let color = line.substring(0, line.indexOf(' '));
381
                    let line_value = line.substring(line.indexOf(' ') + 1);
382
                    var color_line = "<span style='" + color + ";'>" + line_value + "</span>"
383
                    buffer.push(color_line);
384
                } else {
385
                    buffer.push(line);
386
                }
387
            }
388
            saveBuffer();
389
            const div = document.createElement("div");
390
            node.replaceWith(div);
391
            const termynal = new Termynal(div, {
392
                lineData: useLines,
393
                noInit: true,
394
                lineDelay: 500
395
            });
396
            termynals.push(termynal);
397
        });
398
}
399
400
function loadVisibleTermynals() {
401
    termynals = termynals.filter(termynal => {
402
        if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) {
403
            termynal.init();
404
            return false;
405
        }
406
        return true;
407
    });
408
}
409
window.addEventListener("scroll", loadVisibleTermynals);
410
createTermynals();
411
loadVisibleTermynals();