Created
September 21, 2020 11:59
-
-
Save bennadel/6479c5ee54caba38a2aa6fac84935022 to your computer and use it in GitHub Desktop.
Playing Zoom Bingo In Angular 10.1.2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- BEGIN: Play Mode. --> | |
<div *ngIf="( mode === 'play' )" class="content"> | |
<nav class="actions"> | |
<a (click)="gotoMode( 'edit' )">Edit phrases</a> | |
</nav> | |
<app-bingo-board | |
[phrases]="phrases"> | |
</app-bingo-board> | |
<p> | |
Experimental: | |
<a (click)="takeScreenshot()">Take screenshot of bingo board</a> | |
</p> | |
<p *ngIf="screenshotUrl" class="screenshot"> | |
<img [src]="screenshotUrl" /> | |
</p> | |
</div> | |
<!-- END: Play Mode. --> | |
<!-- BEGIN: Edit Mode. --> | |
<div *ngIf="( mode === 'edit' )" class="content"> | |
<nav class="actions"> | |
<a (click)="gotoMode( 'play' )">Back to game</a> | |
</nav> | |
<app-form | |
[phrases]="phrases" | |
(phrasesChange)="applyNewPhrases( $event )"> | |
</app-form> | |
</div> | |
<!-- END: Edit Mode. --> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { Component } from "@angular/core"; | |
import html2canvas from "html2canvas"; | |
// Import the application components and services. | |
import { Utilities } from "./utilities"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
type Mode = "edit" | "play"; | |
@Component({ | |
selector: "app-root", | |
styleUrls: [ "./app.component.less" ], | |
templateUrl: "./app.component.html" | |
}) | |
export class AppComponent { | |
public mode: Mode; | |
public phrases: string[]; | |
public screenshotUrl: string; | |
// I initialize the app component. | |
constructor() { | |
this.mode = "play"; | |
this.screenshotUrl = ""; | |
// Since coming up with phrases was not the "goal" of this code kata, I borrowed | |
// the phrases from this blog post on Vault.com about Zoom Bingo: | |
// -- | |
// https://www.vault.com/blogs/coronavirus/zoom-call-bingo-with-cards-for-your-next-meeting | |
this.phrases = [ | |
"Pet photobomb", | |
"Awkward silence", | |
"House plant in background", | |
"Obviously texting off screen", | |
"Dog barking", | |
"Someone walks in on meeting", | |
"Hey guys, I have another call", | |
"Someone forgets to unmute", | |
"Two people talk at the same time", | |
"Let's take that offline", | |
"Taking the meeting in bed", | |
"Someone has a 'Hard Stop'", | |
"Kids yelling in background", | |
"Can't turn off 'fun' background", | |
"Let's circle back to that", | |
"Firetruck / Ambulance siren", | |
"Action figures / toys in background", | |
"Robot voice", | |
"Turns off camera halfway through", | |
"Only every other word comes through", | |
"Echo", | |
"Can everyone seen my screen?", | |
"Weird background on share screen", | |
"Out of dresscode" | |
]; | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
// I apply the new phrases from the edit-form. | |
public applyNewPhrases( newPhrases: string[] ) : void { | |
this.phrases = newPhrases; | |
this.mode = "play"; | |
this.savePhrasesToUrl(); | |
} | |
// I switch the user over to the given experience. | |
public gotoMode( newMode: Mode ) : void { | |
this.mode = newMode; | |
} | |
// I get called once after the inputs have been bound for the first time. | |
public ngOnInit() : void { | |
// If the URL has been copy-pasted to other individuals, the URL will contain the | |
// phrase definitions for the current game. In such a situation, we have to use | |
// the URL to override the current game state. | |
// -- | |
// NOTE: We're not going to bother registering the "hashchange" event-listener | |
// since it is NOT LIKELY that a user will manually change the hash - instead, | |
// they are much more likely to just copy-paste a URL into a new browser-tab. | |
this.applyHash( window.location.hash.slice( 1 ) ); | |
} | |
// EXPERIMENTAL: I try to take a screenshot of the current bingo-board using the | |
// html2canvas library. | |
public takeScreenshot() : void { | |
// The html2canvas library, at the time of this writing, is having trouble | |
// generating canvas images if the window is scrolled down. To "fix" this, we | |
// need to scroll the user back to the top before we initiate the screenshot. | |
// -- | |
// Read more: https://github.com/niklasvh/html2canvas/issues/1878 | |
window.scrollTo( 0, 0 ); | |
var element = document.querySelector( "app-bingo-board" ) as HTMLElement; | |
// Generate the screenshot using html2canvas. | |
var promise = html2canvas( | |
element, | |
{ | |
logging: false, | |
// CAUTION: These dimensions match the explicit height/width being | |
// applied internally on the app-bingo-board component when the | |
// html2canvas class is injected. Which means, these values have to be | |
// kept in sync with another part of the code. | |
width: 1200, | |
height: 900, | |
// The onclone callback gives us access to the cloned DOCUMENT before the | |
// screenshot is generated. This gives us the ability to make edits to | |
// the DOM that won't affect the original page content. In this case, I | |
// am applying a special CSS class that allows me to set a fixed-size for | |
// the bingo-board in order to get the screenshot to prevent clipping. | |
onclone: ( doc ) => { | |
doc.querySelector( "app-bingo-board" )!.classList.add( "html2canvas" ); | |
} | |
} | |
); | |
promise | |
.then( | |
( canvas ) => { | |
// Once the screenshot has been generated (as a canvas element), we | |
// can grab the PNG data URI which we can then use to render an IMG | |
// tag in the app. | |
this.screenshotUrl = canvas.toDataURL(); | |
// Once the change-detection has had time to reconcile the View with | |
// the View-model, our screenshot should be rendered on the page. | |
// Let's try to scroll the user down to the IMG. | |
setTimeout( | |
() => { | |
document.querySelector( ".screenshot" )!.scrollIntoView({ | |
block: "start", | |
behavior: "smooth" | |
}); | |
}, | |
100 | |
); | |
} | |
) | |
.catch( | |
( error ) => { | |
console.warn( "An error occurred." ); | |
console.error( error ); | |
} | |
) | |
; | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
// I apply the given hash to the current game state. | |
private applyHash( base64Value: string ) : void { | |
// If the hash is empty, then update the hash to reflect the current state of | |
// the bingo board. This way, the user will be setup to copy-paste the current | |
// URL over to other participants. | |
if ( ! base64Value ) { | |
this.savePhrasesToUrl(); | |
return; | |
} | |
try { | |
this.phrases = Utilities.base64UrlDecode( base64Value ) | |
.split( /&/g ) | |
.map( | |
( rawPhrase ) => { | |
return( decodeURIComponent( rawPhrase ) ); | |
} | |
) | |
.filter( | |
( phrase ) => { | |
return( !! phrase ); | |
} | |
) | |
; | |
} catch ( error ) { | |
console.group( "Error decoding URL" ); | |
console.error( error ); | |
console.groupEnd(); | |
} | |
} | |
// I update the URL hash to reflect the current phrases configuration. This allows | |
// the bingo game to be copy-pasted to other participants. | |
private savePhrasesToUrl() : void { | |
var encodedPhrases = this.phrases | |
.map( | |
( phrase ) => { | |
return( encodeURIComponent( phrase ) ); | |
} | |
) | |
.join( "&" ) | |
; | |
window.location.hash = Utilities.base64UrlEncode( encodedPhrases ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<ul id="bingo-board" class="card"> | |
<li | |
*ngFor="let space of spaces; index as index ;" | |
(click)="toggleIndex( index )" | |
class="space" | |
[class.space--selected]="selectedIndices[ index ]"> | |
{{ space }} | |
</li> | |
</ul> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { ChangeDetectionStrategy } from "@angular/core"; | |
import { Component } from "@angular/core"; | |
// Import the application components and services. | |
import { Utilities } from "./utilities"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
// I define the minimum number of spaces that there can be on the bingo board. If an | |
// insufficient number of phrases are passed-in, the rest of the spaces will be padded | |
// with the filler phrase. | |
var MIN_LENGTH = 25; | |
var FILLER_PHRASE = "(Free Space)"; | |
interface SelectedIndices { | |
[ key: string ]: boolean; | |
} | |
@Component({ | |
selector: "app-bingo-board", | |
inputs: [ "phrases" ], | |
changeDetection: ChangeDetectionStrategy.OnPush, | |
styleUrls: [ "./bingo-board.component.less" ], | |
templateUrl: "./bingo-board.component.html" | |
}) | |
export class BingoBoardComponent { | |
public phrases: string[]; | |
public selectedIndices: SelectedIndices; | |
public spaces: string[]; | |
// I initialize the bingo-board component. | |
constructor() { | |
this.phrases = []; | |
this.selectedIndices = Object.create( null ); | |
this.spaces = []; | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
// I get called when any of the input bindings have been updated. | |
public ngOnChanges() : void { | |
this.selectedIndices = Object.create( null ); | |
this.spaces = this.selectRandomPhrases(); | |
} | |
// I toggle the space at the given index. | |
public toggleIndex( index: number ) : void { | |
this.selectedIndices[ index ] = ! this.selectedIndices[ index ]; | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
// I select a randomly-sorted assortment of phrases for the board. | |
private selectRandomPhrases() : string[] { | |
var selectedPhrases = this.phrases.slice(); | |
while ( selectedPhrases.length < MIN_LENGTH ) { | |
selectedPhrases.push( FILLER_PHRASE ); | |
} | |
return( Utilities.arrayShuffle( selectedPhrases ).slice( 0, MIN_LENGTH ) ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<p> | |
To <em>delete</em> an option, just leave it blank. | |
</p> | |
<form (submit)="processForm()"> | |
<div *ngFor="let option of options" class="option"> | |
<!-- | |
NOTE: Since we're using template-driven forms, we have to provide the [name] | |
property so that the ngModel control can register itself with the parent | |
form. In our case, this is basically a "throw away" value since we never | |
reference it explicitly; but, it is required for compilation. | |
--> | |
<input | |
type="text" | |
[name]="option.name" | |
[(ngModel)]="option.value" | |
class="input" | |
/> | |
</div> | |
<div class="actions"> | |
<button type="submit"> | |
Submit Changes | |
</button> | |
<a (click)="addOptions()"> | |
Add more options | |
</a> | |
</div> | |
</form> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { ChangeDetectionStrategy } from "@angular/core"; | |
import { Component } from "@angular/core"; | |
import { EventEmitter } from "@angular/core"; | |
import { SimpleChanges } from "@angular/core"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
interface PhraseOption { | |
id: number; | |
name: string; | |
value: string; | |
} | |
@Component({ | |
selector: "app-form", | |
inputs: [ "phrases" ], | |
outputs: [ "phrasesChangeEvents: phrasesChange" ], | |
changeDetection: ChangeDetectionStrategy.OnPush, | |
styleUrls: [ "./form.component.less" ], | |
templateUrl: "./form.component.html" | |
}) | |
export class FormComponent { | |
public options: PhraseOption[]; | |
public phrases: string[]; | |
public phrasesChangeEvents: EventEmitter<string[]>; | |
// I initialize the form component. | |
constructor() { | |
this.options = []; | |
this.phrases = []; | |
this.phrasesChangeEvents = new EventEmitter(); | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
// I add an empty option to the input list. | |
public addOption() : void { | |
var nextID = this.options.length; | |
this.options.push({ | |
id: nextID, | |
name: `phrase_${ nextID }`, | |
value: "" | |
}); | |
} | |
// I add a number of empty options to the input list. | |
public addOptions() : void { | |
for ( var i = 0 ; i < 5 ; i++ ) { | |
this.addOption(); | |
} | |
} | |
// I get called once after the inputs have been bound for the first time. | |
public ngOnInit() : void { | |
this.options = this.phrases.map( | |
( phrase, index ) => { | |
return({ | |
id: index, | |
name: `phrase_${ index }`, | |
value: phrase | |
}); | |
} | |
); | |
// Let's encourage the creation of at least 25-phrases. The user doesn't need to | |
// include all of them - phrases will be padded if an insufficient number is | |
// provided. But, it would be best if 25+ were defined. | |
while ( this.options.length < 25 ) { | |
this.addOption(); | |
} | |
} | |
// I process the form, emitting a new collection of phrases to be used in the game. | |
public processForm() : void { | |
var newPhrases = this.options | |
.map( | |
( option ) => { | |
return( option.value.trim() ); | |
} | |
) | |
.filter( | |
( phrase ) => { | |
return( !! phrase ); | |
} | |
) | |
; | |
this.phrasesChangeEvents.emit( newPhrases ); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment