|
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(); |