Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created September 21, 2020 11:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/6479c5ee54caba38a2aa6fac84935022 to your computer and use it in GitHub Desktop.
Save bennadel/6479c5ee54caba38a2aa6fac84935022 to your computer and use it in GitHub Desktop.
Playing Zoom Bingo In Angular 10.1.2
<!-- 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. -->
// 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 );
}
}
<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>
// 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 ) );
}
}
<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>
// 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