` to simplify component maintenance
40 |
41 | ## Credits
42 |
43 | * [MIT](./LICENSE)
44 |
--------------------------------------------------------------------------------
/demo-pre.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | squirm-inal Web Component Demo
8 |
9 |
10 |
11 |
12 | squirminal Web Component Demo
13 | Go back to the repository on GitHub .
14 |
15 |
16 | Without <squirm-inal>:
17 |
18 | class MyComponent extends HTMLElement {
19 | connectedCallback ( ) {
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | }
29 | }
30 | if ( "customElements" in window) {
31 | customElements. define ( "my-component" , MyComponent) ;
32 | }
33 |
34 |
35 | With <squirm-inal>:
36 |
37 |
38 | class MyComponent extends HTMLElement {
39 | connectedCallback ( ) {
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | }
49 | }
50 | if ( "customElements" in window) {
51 | customElements. define ( "my-component" , MyComponent) ;
52 | }
53 |
54 |
55 |
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | squirm-inal Web Component Demo
8 |
9 |
18 |
19 |
20 | squirminal Web Component Demo
21 | Go back to the repository on GitHub .
22 |
23 | Plain
24 |
25 |
26 |
27 |
28 |
29 | Accessible Text
30 |
31 |
32 |
33 |
34 | [2021-11-17T23:41:07.790Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
35 | [2021-11-17T23:41:07.791Z] "GET /favicon.ico" Error (404): "Not found"
36 | [2021-11-17T23:41:41.895Z] "GET /demo.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
37 | [2021-11-17T23:41:41.944Z] "GET /demo.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
38 | [2021-11-17T23:41:41.964Z] "GET /squirminal.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
39 | [2021-11-17T23:41:41.964Z] "GET /demo.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
40 | [2021-11-17T23:41:41.964Z] "GET /squirminal.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
41 | [2021-11-17T23:41:41.979Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
42 |
43 |
44 |
45 |
46 | This one reveals characters with every keypress (mash your keyboard).
47 |
48 |
49 | [2021-11-17T23:41:07.790Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
50 | [2021-11-17T23:41:07.791Z] "GET /favicon.ico" Error (404): "Not found"
51 | [2021-11-17T23:41:41.895Z] "GET /demo.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
52 | [2021-11-17T23:41:41.944Z] "GET /demo.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
53 | [2021-11-17T23:41:41.964Z] "GET /squirminal.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
54 | [2021-11-17T23:41:41.964Z] "GET /demo.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
55 | [2021-11-17T23:41:41.964Z] "GET /squirminal.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
56 | [2021-11-17T23:41:41.979Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
57 |
58 |
59 |
60 | With Cursor
61 |
62 |
63 | [2021-11-17T23:41:07.790Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
64 | [2021-11-17T23:41:07.791Z] "GET /favicon.ico" Error (404): "Not found"
65 | [2021-11-17T23:41:41.895Z] "GET /demo.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
66 | [2021-11-17T23:41:41.944Z] "GET /demo.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
67 | [2021-11-17T23:41:41.964Z] "GET /squirminal.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
68 | [2021-11-17T23:41:41.964Z] "GET /demo.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
69 | [2021-11-17T23:41:41.964Z] "GET /squirminal.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
70 | [2021-11-17T23:41:41.979Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
71 |
72 |
73 |
74 | Works with arbitrary HTML content
75 |
76 | This one has a link
77 |
78 |
79 | Test[2021-11-17T23:41:07.790Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh ; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
80 | [2021-11-17T23:41:07.791Z] "GET /favicon.ico" Error (404): "Not found"
81 | [2021-11-17T23:41:41.895Z ] "GET /demo.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
82 | [2021-11-17T23:41:41.944Z] "GET /demo.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
83 | [2021-11-17T23:41:41.964Z] "GET /squirminal.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
84 | [2021-11-17T23:41:41.964Z] "GET /demo.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
85 | [2021-11-17T23:41:41.964Z] "GET /squirminal.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
86 | [2021-11-17T23:41:41.979Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
87 |
88 |
89 |
90 | This one has a table
91 |
92 |
175 |
176 |
177 | With Autoplay
178 |
179 |
180 | [2021-11-17T23:41:07.790Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
181 | [2021-11-17T23:41:07.791Z] "GET /favicon.ico" Error (404): "Not found"
182 | [2021-11-17T23:41:41.895Z] "GET /demo.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
183 | [2021-11-17T23:41:41.944Z] "GET /demo.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
184 |
185 |
[2021-11-17T23:41:41.964Z] "GET /squirminal.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
186 |
187 | [2021-11-17T23:41:41.964Z] "GET /demo.js" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
188 | [2021-11-17T23:41:41.964Z] "GET /squirminal.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
189 | [2021-11-17T23:41:41.979Z] "GET /favicon.ico" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0"
190 |
191 |
192 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/squirminal.js:
--------------------------------------------------------------------------------
1 | class Squirminal extends HTMLElement {
2 | static define() {
3 | if(!("customElements" in window)) {
4 | return;
5 | }
6 | // tagName was removed (it didn’t work anyway)
7 | window.customElements.define(this.tagName, Squirminal);
8 | }
9 |
10 | static tagName = "squirm-inal";
11 |
12 | static attr = {
13 | cursor: "cursor",
14 | autoplay: "autoplay",
15 | buttons: "buttons",
16 | global: "global",
17 | dimensions: "dimensions",
18 | speed: "speed",
19 | };
20 |
21 | static classes = {
22 | showCursor: "show-cursor",
23 | emptyNode: "sq-empty",
24 | cursor: "sq-cursor",
25 | };
26 |
27 | static css = `
28 | ${Squirminal.tagName} {
29 | --sq-cursor: #30c8c9;
30 | display: block;
31 | }
32 | ${Squirminal.tagName} .${Squirminal.classes.emptyNode} {
33 | display: none;
34 | }
35 | ${Squirminal.tagName}.${Squirminal.classes.showCursor}.${Squirminal.classes.cursor}:after,
36 | ${Squirminal.tagName}.${Squirminal.classes.showCursor} .${Squirminal.classes.cursor}:after {
37 | content: "";
38 | display: inline-block;
39 | width: 0.7em;
40 | height: 1.2em;
41 | margin-left: 0.2em;
42 | background-color: var(--sq-cursor);
43 | vertical-align: text-bottom;
44 | animation: squirminal-blink 1s infinite steps(2, start);
45 | }
46 | @keyframes squirminal-blink {
47 | 0% {
48 | background-color: var(--sq-cursor);
49 | }
50 | 100% {
51 | background-color: transparent;
52 | }
53 | }`
54 |
55 | static defaultSpeed = 2; // higher is faster, 10 is about the fastest it can go.
56 |
57 | static chunkSize = {
58 | min: 5,
59 | max: 30
60 | };
61 |
62 | static flatDepth = 1000;
63 |
64 | static events = {
65 | start: "squirminal.start",
66 | end: "squirminal.end",
67 | frameAdded: "squirminal.frameadded",
68 | };
69 |
70 | static _needsCss = true;
71 |
72 | _serializeContent(node, selector = [], shouldTrim = false) {
73 | if(node.nodeType === 3) {
74 | let text = node.nodeValue;
75 | if(shouldTrim) {
76 | text = text.trim();
77 | }
78 | node.nodeValue = "";
79 |
80 | // this represents characters that need to be added to the page.
81 | return {
82 | text: text.split(""),
83 | selector,
84 | };
85 | } else if(node.nodeType === 1) {
86 | if(node.tagName.toLowerCase() !== Squirminal.tagName) {
87 | node.classList.add(Squirminal.classes.emptyNode);
88 | }
89 | if(!node.innerText) {
90 | return {
91 | text: false,
92 | selector,
93 | }
94 | }
95 | }
96 |
97 | let content = [];
98 | let j = 0;
99 |
100 | for(let child of Array.from(node.childNodes)) {
101 | content.push(this._serializeContent(child, [...selector, j], shouldTrim));
102 | j++;
103 | }
104 |
105 | return content;
106 | }
107 |
108 | static getNode(target, selector) {
109 | for(let childIndex of selector) {
110 | target = target.childNodes[childIndex];
111 | }
112 | return target;
113 | }
114 |
115 | static removeEmpty(node) {
116 | while(node) {
117 | if(node.classList) {
118 | node.classList.remove(this.classes.emptyNode);
119 | }
120 | if(node.parentNode?.tagName.toLowerCase() === this.tagName) {
121 | break;
122 | }
123 | node = node.parentNode;
124 | }
125 | }
126 |
127 | static removeAllEmptyChildren(node) {
128 | node.querySelectorAll(`:scope .${this.classes.emptyNode}`).forEach(el => el.classList.remove(this.classes.emptyNode));
129 | }
130 |
131 | swapCursor(node) {
132 | if(!node || !node.classList) {
133 | return;
134 | }
135 | if(this._lastCursor) {
136 | this._lastCursor.classList.remove(Squirminal.classes.cursor);
137 | }
138 | node.classList.add(Squirminal.classes.cursor);
139 | this._lastCursor = node;
140 | }
141 |
142 | addCharacters(target, characterCount = 1) {
143 | for(let entry of this.serialized) {
144 | let str = [];
145 | while(entry.text && entry.text.length > 0 && characterCount-- > 0) {
146 | str.push(entry.text.shift());
147 | }
148 |
149 | let targetNode = Squirminal.getNode(target, entry.selector);
150 | if(entry.text !== false) {
151 | targetNode.nodeValue += str.join("");
152 | }
153 | if(entry.text && entry.text.length > 0) {
154 | this.swapCursor(targetNode.parentNode);
155 | }
156 | if(entry.text === false) {
157 | Squirminal.removeAllEmptyChildren(targetNode);
158 | }
159 | Squirminal.removeEmpty(targetNode);
160 |
161 | if(characterCount < 0) {
162 | break;
163 | }
164 | }
165 | }
166 |
167 | hasQueue() {
168 | for(let entry of this.serialized) {
169 | if(entry.text.length > 0) {
170 | return true;
171 | }
172 | }
173 | return false;
174 | }
175 |
176 | connectedCallback() {
177 | if (!("replaceSync" in CSSStyleSheet.prototype)) {
178 | return;
179 | }
180 |
181 | Squirminal._addCss();
182 |
183 | if(this.hasAttribute(Squirminal.attr.dimensions)) {
184 | this.style.minHeight = `${this.offsetHeight}px`;
185 | }
186 |
187 | this.init();
188 |
189 | // TODO this is not ideal because the intersectionRatio is based on the empty terminal, not the
190 | // final animated version. So it’s tiny when empty and when the IntersectionRatio is 1 it may
191 | // animate off the bottom of the viewport.
192 | if(this.hasAttribute(Squirminal.attr.autoplay)) {
193 | this._whenVisible(this, (isVisible) => {
194 | if(isVisible) {
195 | this.play();
196 | }
197 | });
198 | }
199 |
200 | if(this.hasAttribute(Squirminal.attr.cursor)) {
201 | // show until finished
202 | if(this.getAttribute(Squirminal.attr.cursor) === "manual") {
203 | this.classList.add(Squirminal.classes.showCursor);
204 | this.swapCursor(this);
205 | }
206 |
207 | this.addEventListener("squirminal.start", () => {
208 | this.classList.add(Squirminal.classes.showCursor);
209 | });
210 |
211 | this.addEventListener("squirminal.end", () => {
212 | this.classList.remove(Squirminal.classes.showCursor);
213 | });
214 | }
215 |
216 |
217 | let href = this.getAttribute("href");
218 | if(href) {
219 | this.addEventListener("squirminal.end", () => {
220 | window.location.href = href;
221 | });
222 | }
223 | }
224 |
225 | static _addCss() {
226 | if(!Squirminal._needsCss) {
227 | return;
228 | }
229 |
230 | Squirminal._needsCss = false;
231 | let sheet = new CSSStyleSheet();
232 | sheet.replaceSync(Squirminal.css);
233 | document.adoptedStyleSheets.push(sheet);
234 | }
235 |
236 | init() {
237 | this.paused = true;
238 | this.originalContent = this.cloneNode(true);
239 |
240 | let isCursorManual = this.getAttribute(Squirminal.attr.cursor) === "manual";
241 | this.serialized = this._serializeContent(this, [], isCursorManual).flat(Squirminal.flatDepth);
242 |
243 | // add non-text that have already been emptied by the serializer
244 | for(let child of Array.from(this.childNodes)) {
245 | this.appendChild(child);
246 | }
247 |
248 | // Play/pause button
249 | this.toggleButton = this.querySelector("button[data-sq-toggle]");
250 | if(this.hasAttribute(Squirminal.attr.buttons) && !this.toggleButton) {
251 | let toggleBtn = document.createElement("button");
252 | toggleBtn.innerText = "Play";
253 | toggleBtn.setAttribute("data-sq-toggle", "");
254 | toggleBtn.addEventListener("click", e => {
255 | this.toggle();
256 | })
257 | this.appendChild(toggleBtn);
258 | this.toggleButton = toggleBtn;
259 | }
260 |
261 | this.skipButton = this.querySelector("button[data-sq-skip]");
262 | if(this.hasAttribute(Squirminal.attr.buttons) && !this.skipButton) {
263 | let skipBtn = document.createElement("button");
264 | skipBtn.innerText = "Skip";
265 | skipBtn.setAttribute("data-sq-skip", "");
266 | skipBtn.addEventListener("click", e => {
267 | this.skip();
268 | })
269 | this.appendChild(skipBtn);
270 | this.skipButton = skipBtn;
271 | }
272 | }
273 |
274 | removeButtons() {
275 | this.toggleButton?.remove();
276 | this.skipButton?.remove();
277 | }
278 |
279 | onreveal(callback) {
280 | this.addEventListener(Squirminal.events.frameAdded, callback, {
281 | passive: true,
282 | });
283 | this.addEventListener(Squirminal.events.end, () => {
284 | this.removeEventListener(Squirminal.events.frameAdded, callback);
285 | }, {
286 | passive: true,
287 | once: true,
288 | });
289 | }
290 |
291 | onstart(callback) {
292 | this.addEventListener(Squirminal.events.start, callback, {
293 | passive: true,
294 | once: true,
295 | });
296 | }
297 |
298 | onend(callback) {
299 | this.addEventListener(Squirminal.events.end, callback, {
300 | passive: true,
301 | once: true,
302 | });
303 | }
304 |
305 | _whenVisible(el, callback) {
306 | if(!('IntersectionObserver' in window)) {
307 | // run by default without intersectionobserver
308 | callback(undefined);
309 | return;
310 | }
311 |
312 | return new IntersectionObserver(entries => {
313 | entries.forEach(entry => {
314 | callback(entry.isIntersecting)
315 | });
316 | }, {
317 | threshold: 1
318 | }).observe(el);
319 | }
320 |
321 | toggle() {
322 | if(this.paused) {
323 | this.play();
324 | } else {
325 | this.pause();
326 | }
327 | }
328 |
329 | pause() {
330 | this.paused = true;
331 | }
332 |
333 | skip() {
334 | this.play({
335 | chunkSize: this.originalContent.innerHTML.length,
336 | delay: 0
337 | });
338 | }
339 |
340 | play(overrides = {}) {
341 | if(window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
342 | overrides.chunkSize = this.originalContent.innerHTML.length;
343 | overrides.delay = 0;
344 | }
345 |
346 | this.paused = false;
347 | if(this.hasQueue()) {
348 | this.dispatchEvent(new CustomEvent(Squirminal.events.start));
349 | }
350 |
351 | this.removeButtons();
352 |
353 | requestAnimationFrame(() => this.showMore(overrides, true));
354 | }
355 |
356 | showMore(overrides = {}, continuePlaying = false) {
357 | if(this.paused && !overrides.force) {
358 | return;
359 | }
360 |
361 | if(!this.hasQueue()) {
362 | this.pause();
363 | this.dispatchEvent(new CustomEvent(Squirminal.events.frameAdded));
364 | this.dispatchEvent(new CustomEvent(Squirminal.events.end));
365 | return;
366 | }
367 |
368 | // show a random chunk size between min/max
369 | let chunkSize = overrides.chunkSize || Math.round(Math.max(Squirminal.chunkSize.min, Math.random() * Squirminal.chunkSize.max + 1));
370 | this.addCharacters(this, chunkSize);
371 |
372 | this.dispatchEvent(new CustomEvent(Squirminal.events.frameAdded));
373 |
374 | if(continuePlaying) {
375 | this.animateNextFrame(chunkSize, overrides);
376 | }
377 | }
378 |
379 | animateNextFrame(chunkSize, overrides = {}) {
380 | let speed = parseFloat(this.getAttribute(Squirminal.attr.speed) || Squirminal.defaultSpeed);
381 | let normalizedSpeed = speed * .3; // convert from 0-10 to 0-3
382 |
383 | // the amount we wait is based on how many non-whitespace characters printed to the screen in this chunk
384 | let delay = overrides.delay > -1 ? overrides.delay : chunkSize * (1/normalizedSpeed);
385 | if(delay > 16) {
386 | setTimeout(() => {
387 | requestAnimationFrame(() => this.showMore(overrides, true));
388 | }, delay);
389 | } else {
390 | requestAnimationFrame(() => this.showMore(overrides, true));
391 | }
392 | }
393 |
394 | isGlobalCommand() {
395 | return this.hasAttribute(Squirminal.attr.global);
396 | }
397 | }
398 |
399 | Squirminal.define();
400 |
--------------------------------------------------------------------------------