Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson ( @KenAuenson )

Organizing My Reaction GIFs Using Node.js, Sharp, And Angular 11.0.2

By on

At InVision, we use GIFs all the time in our Slack chat to add color and nuance to our text-based conversations. But, when it comes to using the right GIF at the right time, I run into several hurdles: first, I find it hard to locate the best-fitting GIF in my MacOS Finder; and second, when I upload the GIF to Slack, it sometimes takes a really long time to process which means my GIF shows up late and fails to deliver the desired sentiment. As such, I thought it would be a fun distraction from 2020 to organize my GIFs on a Netlify site using Angular 11.0.2 such that I could quickly locate, preview, and link to a given GIF without having to upload it to Slack.

Run this demo in my Reaction GIFs site on Netlify.

View this code in my Reaction GIFs project on GitHub.

A Failed Experiment

Ultimately, the underlying theory for this whole exercise - which turned out to be wrong - was that I would be able to drop a URL to a GIF in my Slack chat and render the GIF quickly without having to deal with the uploading and processing of the GIF binary. Unfortunately, this didn't work as well as I wanted it to. It seems that there are different restrictions about what Slack will render when you link to an image vs. when you upload an image.

When I upload a GIF in Slack, it seems to always render. However, if I link to a GIF in Slack, it will only auto-expand (ie, show the GIF to the other chat-room members) when the GIF file-size is under 3Mb in size. If it's over that size, the Slack user will have to click on a link within the chat to expand the GIF manually. Which, I have to assume, is the chat-based equivalent of having to explain a joke.

A Fun Experiment Nevertheless

Even though this experiment was ultimately not what I had hoped it would be, it was still fun to build. As such, I'd still like to share the journey. The first step for me was figuring out how to generate thumbnails of my GIFs. I didn't want to render all the GIFs directly in my Angular app, since it would be hundreds of megabytes. Instead, I wanted to render static thumbnails; and then, replace each thumbnail with the relevant GIF during a mouseenter event.

To keep things as simple as possible, I decided to generate the thumbnails locally - on my development machine - using Sharp. Then, I would just commit both the GIF images and the thumbnail images to my repository. I'd never actually used Sharp before; but, it ended up being super easy and wicked fast.

All it took was a small Node.js script that I could run as an npm run-script. Since I'm committing the images to the repository, it means that I only need to generate thumbnails for new images on each successive run of this script:

// Import core node modules.
var fs = require( "fs" );
var sharp = require( "sharp" );

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

var inputsDirectory = ( __dirname + "/../src/assets/gifs/" );
var outputsDirectory = ( __dirname + "/../src/assets/thumbnails/" );

generateThumbnailsForGIFs().then(
	( thumbnailCount ) => {

		console.log( `Generated ${ thumbnailCount } thumbnails.` );

	},
	( error ) => {

		console.warn( "An error occurred while generating thumbnails." );
		console.error( error );

	}
);

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I generate thumbnails if and only if they don't yet exist. Returns the number of
// thumbnails that were generated.
async function generateThumbnailsForGIFs() {

	var thumbnailCount = 0;

	for ( var inputFileName of fs.readdirSync( inputsDirectory ) ) {

		var thumbnailFileName = inputFileName.replace( /\.gif$/i, ".jpg" );
		var inputFilePath = ( inputsDirectory + inputFileName );
		var outputFilePath = ( outputsDirectory + thumbnailFileName );

		if ( fileExists( outputFilePath ) ) {

			console.warn( `Skipping "${ thumbnailFileName }", it already exists.` );

		} else {

			console.log( `Generating thumbnail for "${ thumbnailFileName }".` );

			await sharp( inputFilePath )
				.flatten()
				.jpeg({
					quality: 70
				})
				.toFile( outputFilePath )
			;

			thumbnailCount++;

		}

	}

	return( thumbnailCount );

}


// I check to see if the given file exists.
function fileExists( filePath ) {

	try {

		return( fs.accessSync( filePath ) === undefined );

	} catch ( error ) {

		return( false );

	}

}

As you can see, I'm iterating over the GIFs directory, loading them in which Sharp, flattening them, and then saving them as JPG thumbnails (with no resizing). The most fun part of this was running this as a compound npm run-script which used a say command at the end. Here are the relevant scripts:

  • "images" → "npm run thumbnails && npm run thumbnails-done"
  • "thumbnails" → "node ./scripts/thumbnails.js"
  • "thumbnails-done" → "say -v Fiona 'Thumbnails have been generated'"

With this, if I run npm run images, it generates the thumbnails and then tells me that the generation completed using the say command that comes bundled with MacOS. If you've never used the say command, it's just fun! It's as easy as running say on the command-line and providing some text (with an optional voice selection). Example:

say -v Fiona "If you're lost, you can look, and you will find me, Time after time."

Once I had my GIFs and my static thumbnails, I had to create a "manifest" of the images. The manifest would contain the filenames plus any additional organizational meta-data that I would want to search. For example, each GIF would be tagged with N-number of phrases. This was a very manual process. However, since the GIF collection would be built-up incrementally over time, I didn't see this as much of a problem.

Here's a truncated version of the manifest.ts file:

export interface Entry {
	fileName: string;
	altText: string;
	tags: string[];
}

export var manifest: Entry[] = [
	{
		"fileName": "i-feel-like-that-needs-to-be-celebrated.gif",
		"altText": "I feel like that needs to be celebrated",
		"tags": [ "Schitt's Creek", "Celebration", "Team Work" ]
	},
	{
		"fileName": "nomo-fomo.gif",
		"altText": "Nomo Fomo",
		"tags": [ "Broad City", "Celebration", "Happy" ]
	},
	{
		"fileName": "stay-strong-babe.gif",
		"altText": "Stay strong, babe",
		"tags": [ "Schitt's Creek", "Good Luck", "Sympathy", "Encouragement" ]
	},
	{
		"fileName": "what-is-this.gif",
		"altText": "What is this?",
		"tags": [ "Homeland", "Shock", "Surprise", "Wat", "Anger", "Tricked" ]
	},
	// .... truncated ....
];

Ultimately, my Angular 11 application would then pull-in this manifest file, render the list of GIFs, and allow me to copy the image URLs into my clipboard. When all is said and done, this simple Netlify site looks like this:

There's not a whole lot of code behind this - it's essentially two components: my App component and my GIF component. Here's my App component code - it takes the manifest file from above and maps it to a "Search Items" collection which can be mutated based on the search query:

// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { ClipboardService } from "./clipboard.service";
import { Entry } from "./manifest";
import { manifest } from "./manifest";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface SearchItem {
	isVisible: boolean;
	searchTarget: string;
	imageUrl: string;
	thumbnailUrl: string;
	sortTarget: number;
	entry: Entry;
}

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	templateUrl: "./app.component.html"
})
export class AppComponent {

	public isShowingNoResults: boolean;
	public isShowingToaster: boolean;
	public searchItems: SearchItem[];

	private clipboardService: ClipboardService;

	// I initialize the app component.
	constructor( clipboardService: ClipboardService ) {

		this.clipboardService = clipboardService;

		this.isShowingNoResults = false;
		this.isShowingToaster = false;
		this.searchItems = this.compileSearchItems();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I apply the given filter to the search results.
	public applyFilter( inputFilter: string ) : void {

		var normalizedFilter = this.normalizeFilterValue( inputFilter );

		for ( var item of this.searchItems ) {

			item.isVisible = ( normalizedFilter )
				? item.searchTarget.includes( normalizedFilter )
				: true
			;

		}

		// If the filtering results in no visible items, show the "no results" message.
		this.isShowingNoResults = ! this.searchItems.some(
			( item ) => {

				return( item.isVisible );

			}
		);

		// Whenever the user changes the filter, scroll them back to the top of the
		// window so that they can see the most relevant results.
		window.scrollTo( 0, 0 );

	}


	// I copy the image URL associated with the given search item to the clipboard.
	public copyImageUrl( item: SearchItem ) : void {

		var remoteImageUrl = ( window.location.href + item.imageUrl );

		console.group( "Copying GIF Url" );
		console.log( remoteImageUrl );
		console.log( item.entry.altText );
		console.groupEnd();

		this.clipboardService
			.copy( remoteImageUrl )
			.then(
				() => {

					this.showToaster();

				},
				( error ) => {

					console.warn( "Could not copy GIF Url" );
					console.error( error );

				}
			)
		;

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I compile the manifest down into a search items collection.
	private compileSearchItems() : SearchItem[] {

		var items: SearchItem[] = manifest.map(
			( entry ) => {

				var imageUrl = `assets/gifs/${ entry.fileName }`;
				var thumbnailUrl = ( "assets/thumbnails/" + entry.fileName.replace( /\.gif$/i, ".jpg" ) );

				// Add all text-based elements to the search target.
				var searchTarget = [ entry.fileName, entry.altText, ...entry.tags ]
					.map( this.normalizeFilterValue )
					.join( "\n" )
					.toLowerCase()
				;

				// In order to bring different GIFs to your mental fore-brain, let's
				// randomly sort the search items every time the page renders. This way,
				// you'll see different GIFs at the top every time.
				var sortTarget = Math.random();

				return({
					isVisible: true,
					searchTarget: searchTarget,
					imageUrl: imageUrl,
					thumbnailUrl: thumbnailUrl,
					sortTarget: sortTarget,
					entry: entry
				});

			}
		);

		items.sort(
			( a, b ) => {

				return( b.sortTarget - a.sortTarget );

			}
		);

		return( items );

	}


	// I normalize the given search filter so that we can get better matches.
	private normalizeFilterValue( value: string ) : string {

		var normalizedValue = value
			.trim()
			.toLowerCase()
			.replace( /['",.:-]/g, "" )
		;

		return( normalizedValue );

	}


	// I show the "copied to clipboard" toaster.
	private showToaster() : void {

		this.isShowingToaster = true;

		setTimeout(
			() => {

				this.isShowingToaster = false;

			},
			3000
		);

	}

}

And, it's HTML template:

<header class="header">

	<h1 class="header__title">
		Ben's Reaction GIFs
	</h1>

	<div class="header__form">
		<input
			#filterRef
			type="text"
			autofocus
			placeholder="Search GIFs..."
			(input)="applyFilter( filterRef.value )"
			(window:keydown.Meta.F)="$event.preventDefault(); filterRef.focus(); filterRef.select();"
			class="header__input"
		/>
	</div>

</header>

<div *ngIf="isShowingToaster" class="toaster">
	GIF image Url has been copied to your clipboard
</div>

<div *ngIf="isShowingNoResults" class="no-results">
	Sorry, no GIFs match your search.
</div>

<ul class="items">
	<li
		*ngFor="let item of searchItems"
		class="items__item"
		[hidden]="( ! item.isVisible )">

		<bn-gif
			[imageUrl]="item.imageUrl"
			[thumbnailUrl]="item.thumbnailUrl"
			[altText]="item.entry.altText"
			[tags]="item.entry.tags"
			(click)="copyImageUrl( item )"
			class="items__gif">
		</bn-gif>

	</li>
</ul>

The only thing of any real interesting in this template is the fact that I am overriding the browser's native search directly within my key-bindings. Notice that my search <input> has the following keydown handler:

(window:keydown.Meta.F)="$event.preventDefault(); filterRef.focus(); filterRef.select();"

This binds to the global CMD+F (on MacOS), prevents the default behavior, and then focuses my filter input. In a "user facing" app, this would be a terrible idea. However, since I am building this app for myself, this is exactly what I wanted to happen: piping any search through my Angular-based filtering.

Each GIF within the search items is being rendered by my <bn-gif> component. This is a pretty "dumb component". The only behavior that it has is that it renders the static thumbnailUrl first, only "activating" the GIF upon mouseenter:

// Import the core angular services.
import { Component } from "@angular/core";
import { ChangeDetectionStrategy } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "bn-gif",
	inputs: [
		"imageUrl",
		"thumbnailUrl",
		"altText",
		"tags"
	],
	host: {
		"(mouseenter)": "activateGif()",
		"(mouseleave)": "deactivateGif()"
	},
	changeDetection: ChangeDetectionStrategy.OnPush,
	styleUrls: [ "./gif.component.less" ],
	templateUrl: "./gif.component.html"
})
export class GifComponent {

	public altText!: string;
	public imageUrl!: string;
	public imageSrc: string;
	public tags!: string[];
	public thumbnailUrl!: string;

	// I initialize the gif component.
	constructor() {

		this.imageSrc = "";

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I enable the GIF animation.
	public activateGif() : void {

		this.imageSrc = this.imageUrl;

	}


	// I disable the GIF animation, showing the static thumbnail.
	public deactivateGif() : void {

		this.imageSrc = this.thumbnailUrl;

	}


	// I get called once after the component has been instantiated.
	public ngOnInit() : void {

		this.imageSrc = this.thumbnailUrl;

	}

}

As you can see, all we're doing it swapping out the imageSrc property when the user interacts with the component interface. Which is equally simple in its implementation:

<div class="viewport">
	<img
		[src]="imageSrc"
		[alt]="altText"
		class="viewport__image"
	/>
</div>

<div *ngIf="altText" class="text">
	"{{ altText }}"
</div>

<ul class="tags">
	<li *ngFor="let tag of tags" class="tags__tag">
		{{ tag }}
	</li>
</ul>

And that's all there is to it. Once I had the GIF thumbnails generated, the rest of this app followed pretty quickly. As I mentioned above, however, it ultimately failed since dropping a GIF URL into my Slack usually leaves me with the following:

Slack won't auto-expand GIF URLs over 3Mb in size.

Oh well! It was still fun to make this. And, if nothing else, it introduced me to the Sharp Node.js package, which seems pretty slick.

Want to use code from this post? Check out the license.

Reader Comments

2 Comments

I think you're actually closer to being able to pull this off than you may think. Maybe instead of copying and pasting the image's url, read the image as a blob from the clipboard and write it back out as a blob?

Here's a decent post I ran into recently on something very similar. Haven't had a chance to play with it yet though: https://web.dev/async-clipboard/

15,674 Comments

@Steve,

Ooooh, you beautiful, beautiful man -- you might be onto something. Cause I see, now that you've suggested it, that I can CMD+CTRL+SHIFT+4 to take a screenshot of a portion of my screen, and then paste that screenshot into my Slack messages.

Ok, let me see if the clipboard like holding a few MBs of binary data - thanks for the insight :D

15,674 Comments

Hmm, looks like the Clipboard API only supports PNG images; and, only in Chrome (from what I'm reading). I'll keep digging to see if I can somehow copy a blob into the clipboard; but, so far, it seems like people saying copying images into the clipboard programmatically is a "security risk". Not exactly sure how that's true - any more so than copying text.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel