Add CSS-animation based typing animation
This commit is contained in:
		
							parent
							
								
									0801e85839
								
							
						
					
					
						commit
						31f3a69a1d
					
				
					 5 changed files with 142 additions and 134 deletions
				
			
		|  | @ -1,18 +1,7 @@ | |||
| <extends src="./lib/html/base.html"> | ||||
|   <block name="stylesheets"> | ||||
|     <style> | ||||
|       .no-js .head-line .typed { | ||||
|         visibility: visible; | ||||
|       } | ||||
|       .head-line .typed { | ||||
|         visibility: hidden; | ||||
|       } | ||||
|     </style> | ||||
|   </block> | ||||
| 
 | ||||
|   <block name="content"> | ||||
|     <h1 class="title head-line has-text-weight-normal is-family-monospace"> | ||||
|       $ <span class="typed">Welcome to tlater.net!</span> | ||||
|       $ <span id="typed-welcome"></span> | ||||
|     </h1> | ||||
| 
 | ||||
|     <hr /> | ||||
|  |  | |||
							
								
								
									
										119
									
								
								src/index.ts
									
										
									
									
									
								
							
							
						
						
									
										119
									
								
								src/index.ts
									
										
									
									
									
								
							|  | @ -1,119 +0,0 @@ | |||
| import jQuery from "jquery"; | ||||
| 
 | ||||
| // Helpers
 | ||||
| 
 | ||||
| /** | ||||
|  * "Types" out a DOM element, emulating the way a human might. | ||||
|  */ | ||||
| class Typer { | ||||
|     private element: JQuery; | ||||
|     private text: string; | ||||
|     private cursor: boolean; | ||||
|     private typed: number; | ||||
|     private min: number; | ||||
|     private max: number; | ||||
|     private blink_tick: number; | ||||
|     private blink_timeout: number; | ||||
|     private end?: number; | ||||
| 
 | ||||
|     /** | ||||
|      * Create the typer. | ||||
|      * @param {HTMLElement} element - The element to type. | ||||
|      * @param {number} blink - The time between cursor blinks. | ||||
|      * @param {number} blink_timeout - How long the cursor should keep | ||||
|      *                                 blinking for after the text | ||||
|      *                                 finishes typing. | ||||
|      */ | ||||
|     constructor(element: JQuery<HTMLElement>, blink: number, blink_timeout: number) { | ||||
|         // Retrieve the current content and wipe it. We also make the
 | ||||
|         // element visible if it was hidden.
 | ||||
|         this.element = element; | ||||
|         this.text = this.element.html(); | ||||
|         this.element.html(""); | ||||
|         this.element.css("visibility", "visible"); | ||||
| 
 | ||||
|         this.cursor = false; | ||||
|         this.typed = 0; | ||||
| 
 | ||||
|         this.min = 20; | ||||
|         this.max = 70; | ||||
|         this.blink_tick = blink; | ||||
|         this.blink_timeout = blink_timeout; | ||||
| 
 | ||||
|         this.end = null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start typing. | ||||
|      */ | ||||
|     type() { | ||||
|         this._type(); | ||||
|         this._blink(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Draw the current text line, i.e., anything that has been typed | ||||
|      * so far, and a cursor if it is currently supposed to be on. | ||||
|      * @private | ||||
|      */ | ||||
|     _draw() { | ||||
|         let text = this.text.slice(0, this.typed); | ||||
| 
 | ||||
|         if (this.cursor) { | ||||
|             text += "\u2588"; | ||||
|         } | ||||
| 
 | ||||
|         window.requestAnimationFrame(() => this.element.html(text)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type the next character, and prepare to draw the next one. If | ||||
|      * no new characters are to be drawn, set the end timestamp. | ||||
|      * @private | ||||
|      */ | ||||
|     _type() { | ||||
|         this.typed += 1; | ||||
|         this._draw(); | ||||
| 
 | ||||
|         if (this.typed != this.text.length) | ||||
|             setTimeout(this._type.bind(this), this._type_tick()); | ||||
|         else { | ||||
|             this.end = Date.now(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Make the cursor change blink status, and prepare for the next | ||||
|      * blink. | ||||
|      * @private | ||||
|      */ | ||||
|     _blink() { | ||||
|         this.cursor = !this.cursor; | ||||
|         this._draw(); | ||||
| 
 | ||||
|         // As long as we are typing, keep blinking
 | ||||
|         if (this.typed != this.text.length) | ||||
|             setTimeout(this._blink.bind(this), this.blink_tick); | ||||
|         // Once typing ends, keep going for a little bit
 | ||||
|         else if (Date.now() - this.end < this.blink_timeout) | ||||
|             setTimeout(this._blink.bind(this), this.blink_tick); | ||||
|         // Make sure we get rid of the cursor in the end
 | ||||
|         else { | ||||
|             this.cursor = true; | ||||
|             setTimeout(this._blink.bind(this), this.blink_tick); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate a "human" time for the next character to type. | ||||
|      * @private | ||||
|      */ | ||||
|     _type_tick() { | ||||
|         return Math.round(Math.random() * this.max) + this.min; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| jQuery(($) => { | ||||
|     const typer = new Typer($(".head-line .typed").first(), 500, 3000); | ||||
|     typer.type(); | ||||
| }); | ||||
|  | @ -1,3 +0,0 @@ | |||
| import jQuery from "jquery"; | ||||
| 
 | ||||
| jQuery(($) => $("html").removeClass("no-js")); | ||||
							
								
								
									
										130
									
								
								src/lib/scss/_typed.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/lib/scss/_typed.scss
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,130 @@ | |||
| @use "sass:math"; | ||||
| @use "sass:list"; | ||||
| 
 | ||||
| /// Animate a blinking cursor. | ||||
| @mixin cursor($duration) { | ||||
|   $name: cursor-09d03260130069771b6ddc1cb415f39fdd27ddfab7b01ba91273398c2d245ae4; | ||||
|   // The number of times we need to blink is = the number of full | ||||
|   // seconds (500ms * 2) that fit in the total duration, rounded up, | ||||
|   // and doubled. | ||||
|   $iterations: math.ceil(math.div($duration, 1s)) * 2; | ||||
| 
 | ||||
|   animation: $name ease-in-out 500ms $iterations alternate; | ||||
|   content: " "; | ||||
| 
 | ||||
|   @keyframes #{$name} { | ||||
|     from { | ||||
|       content: " "; | ||||
|     } | ||||
| 
 | ||||
|     to { | ||||
|       content: "█"; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Animate a piece of text as if it was being typed by a human. | ||||
| @mixin typed($text, $duration) { | ||||
|   // We don't want a linearly typed set of text, which makes this | ||||
|   // singificantly more complex. | ||||
|   // | ||||
|   // CSS animations normally do not permit per-frame changes in | ||||
|   // duration (since the total animation time is fixed). This means we | ||||
|   // need to create multiple animations, and delay them so that they | ||||
|   // happen in the time sequence we want. | ||||
|   // | ||||
|   // We generate the raw values with _generate-animations, and then | ||||
|   // split up the result into the animation API. | ||||
|   $frames: str-length($text); | ||||
|   $animations: _generate-animations($frames, 1.2s); | ||||
| 
 | ||||
|   animation-name: _unzip($animations, 1); | ||||
|   animation-delay: _unzip($animations, 3); | ||||
|   animation-fill-mode: forwards; | ||||
|   content: ""; | ||||
| 
 | ||||
|   // We need to type each character in separate animations, see above | ||||
|   // comment. | ||||
|   @each $name, $character in $animations { | ||||
|     @keyframes #{$name} { | ||||
|       from { | ||||
|         content: str-slice($text, 0, $character); | ||||
|       } | ||||
| 
 | ||||
|       to { | ||||
|         content: str-slice($text, 0, $character + 1); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Unzip a nested set of lists, taking the nth value of each sublist. | ||||
| @function _unzip($lists, $i) { | ||||
|   $out: (); | ||||
|   $sep: comma; | ||||
|   @each $sublist in $lists { | ||||
|     $out: list.append($out, list.nth($sublist, $i), $sep); | ||||
|   } | ||||
|   @return $out; | ||||
| } | ||||
| 
 | ||||
| /// Compute the sum of all numbers in a list. | ||||
| @function _sum($list) { | ||||
|   $out: 0; | ||||
|   @each $val in $list { | ||||
|     $out: $out + $val; | ||||
|   } | ||||
|   @return $out; | ||||
| } | ||||
| 
 | ||||
| /// Produce a list from a shorter list by repeating it up until size | ||||
| /// $length. | ||||
| @function _round-robin($base, $length) { | ||||
|   $out: (); | ||||
|   $sep: list.separator($out); | ||||
|   @for $i from 0 through $length { | ||||
|     $out: list.append($out, list.nth($base, $i % list.length($base) + 1)); | ||||
|   } | ||||
|   @return $out; | ||||
| } | ||||
| 
 | ||||
| /// Generate the actual animation values. | ||||
| /// | ||||
| /// This generates a nested list as: | ||||
| /// | ||||
| ///   (keyframe-name, index, start time) | ||||
| /// | ||||
| /// The duration of each frame is taken from the internal $delays in a | ||||
| /// round robin fashion, to give some amount of human-like variance to | ||||
| /// the duration of each frame. | ||||
| /// | ||||
| /// Start time is set to the time at which the frame should start to | ||||
| /// achieve the desired frame-by-frame duration. | ||||
| @function _generate-animations($number, $total_duration) { | ||||
|   $id: d66fa0449c0b4d4ca287f8c96428af928b2987b4d88b72b7d60152d9a55d9f29; | ||||
|   $out: (); | ||||
|   $sep: list.separator($out); | ||||
| 
 | ||||
|   // A set of "human-like" delays for each typed character. In | ||||
|   // practice, my typing seems to be about 20-70ms, but it looks a bit | ||||
|   // nicer to increase all typing by 20ms to make the effect more | ||||
|   // noticeable. | ||||
|   // | ||||
|   // Numbers generated once with a random number generator, rather | ||||
|   // than using `math.random()`, since they end up in CSS verbatim, | ||||
|   // and the build would be non-reproducible if we didn't do it this | ||||
|   // way. Using `math.random() wouldn't change this dynamically each | ||||
|   // time the page loads anyway, so we don't really lose anything by | ||||
|   // pre-generating these numbers. | ||||
|   $delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms; | ||||
| 
 | ||||
|   @for $animation from 0 through $number { | ||||
|     $out: list.append($out, ( | ||||
|       type-#{$id}-#{$animation}, | ||||
|       $animation, | ||||
|       _sum(_round_robin($delays, $animation)) | ||||
|     ), $sep); | ||||
|   } | ||||
| 
 | ||||
|   @return $out; | ||||
| } | ||||
|  | @ -1,6 +1,17 @@ | |||
| @import "./_custom-bulma"; | ||||
| @import "./_typed"; | ||||
| 
 | ||||
| html, body { | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| #typed-welcome { | ||||
|   &::before { | ||||
|     @include typed("Welcome to tlater.net!", 1.2s); | ||||
|   } | ||||
| 
 | ||||
|   &::after { | ||||
|     @include cursor(6s); | ||||
|   } | ||||
| } | ||||
|  |  | |||
		Reference in a new issue