Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Guust Nieuwenhuis
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Guust Nieuwenhuis

The JavaScript "copy" Event - Fun With InVision Freehand Shapes

By
Published in Comments (1)

A couple of weeks ago, my company released a gorgeous white-boarding and sketching tool called Freehand. In a short time, this tool has completely changed the way our all-remote engineering team has conducted internal meetings, finally having a seamless way to hash out architectural and software design problems. One of the cool features of Freehand is that you can copy and paste vector shapes from one Freehand session into another Freehand session. As a fun experiment, I wanted to see if I could leverage this feature as a way to store Freehand shapes - for later use - in an external storage container.

Run this demo in my JavaScript Demos project on GitHub.

While Freehand is an InVision App product, I didn't actually work on it myself. As such, I have next to no insight into how it was built, other than the fact that it's totally player. I do know that you can copy Image objects into Freehand; and, that you can copy and paste shapes (and images) within Freehand and across different Freehand sessions. So, I started poking at the application by adding "copy" and "paste" event handlers to the window using the Chrome Developer Tools console:

(function( win ) {

	win.addEventListener(
		"copy",
		function handleCopy( event ) {

			debugger;

		}
	);

	win.addEventListener(
		"paste",
		function handlePaste( event ) {

			debugger;

		}
	);

})( window );

Since you can copy Freehand shapes across different sessions, I figured the Freehand engineers must be doing something clever with the actual Copy and Paste events; so, the "debugger" statements in the above mixin would allow me to stop the world during those copy and paste events so that I could inspect the Event objects. What I found was that each event had a "Types" collection that included the value, "custom/binary".

Freehand event debugging.

NOTE: If I copy and paste an Image object, the event object is a bit different. This is just for "shape" data.

Other than my recent demo, creating a Copy-to-Clipboard directive in Angular 2, I don't actually know much about the Copy and Paste events. But, according to the Mozilla Developer Network (MDN), Copy and Paste events contain a property called "clipboardData", which is actually an instance of DataTransfer. And, this DataTransfer object allows you to - within an event handler - associate arbitrary data with the event. It looks like the Freehand team is using this "custom/binary" Type as a way to store transferrable Shape-data in the event object.

While in the "debugger" break statement, I was able to look at this custom data in the custom type by creating a "Watch Binding":

WATCH: event.clipboardData.getData( "custom/binary" )

This output a huge list of numbers that looked like (truncated):

10,9,81,76,52,85,108,52,65,113,103,18,198,4,8,1 .....,237,145,63

I have no idea what these numbers represent. My best guess would be some sort of SVG coordinates, but I'm not sure. The good news is, I don't really have to know; as long as the Copy source and Paste destination agree on the meaning of these values, the actual content holds no additional value.

Given this evidence, I started playing around with capturing this "custom/binary" data and injecting it into future "copy" events. What I found was that if I altered the "custom/binary" data of a copy event, it would have a direct impact on the value that was pasted into the Freehand canvas.

Very cool!

To turn this Freehand exploration into a fun demo (see Video), I created a page on GitHub that would allow me to store and retrieve these "custom/binary" event values so that I could use them in future Freehand sessions. Essentially, I was trying to create a repository of shapes.

To do this, the external page needed to perform several actions:

  • Provide a Bookmarklet for gathering shape data.
  • Provide a Bookmarklet to stop gathering shape data.
  • Provide a way to persist shape data.
  • Provide a way to copy persisted shape data back into the Clipboard.

The first two feature work by simply binding and unbinding a "copy" event handler in the current Freehand session. The shape data is then persisted to LocalStorage using some manual copy-and-paste facilitation. And, finally, the shape data can be retrieved by triggering the document.execCommand("copy") operation and calling .setData() on the intercepted event object.

<!doctype html>
<html>
<head>
	<meta charset="utf-8" />

	<title>
		Fun With InVision Freehand Shapes
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css" />
</head>
<body>

	<h1>
		Fun With InVision Freehand Shapes
	</h1>

	<p>
		<a class="start-debug">Get START Debug Bookmarklet</a>
		&nbsp;|&nbsp;
		<a class="stop-debug">Get STOP Debug Bookmarklet</a>
	</p>

	<h2>
		Freehand Shape Copy Links
	</h2>

	<ul class="shapes">
		<!-- Shapes will be loaded from localStroage. -->
	</ul>

	<h2>
		Add Freehand Shape
	</h2>

	<p>
		Paste in the debugging shape data from the <strong>Freehand bookmarklet</strong>.
	</p>

	<form>
		<input type="text" class="shape" />
		<button type="submit" class="submit">Add Shape</button>
	</form>

	<p class="notes">
		Demo based on <a href="https://freehand.invisionapp.com">InVision Freehand</a>
		sketching tool by <a href="https://www.invisionapp.com/?source=bennadel.com">InVision App, Inc</a>.
	</p>


	<!-- I am the TEMPLATE for the start-debugging bookmarklet. -->
	<script type="text/javascript-template" class="start-debug-script">

		javascript:(function ( w, c, data ) {

			w.debugCopy = w.debugCopy || function( e ) {

				if ( data = e.clipboardData.getData( "custom/binary" ) ) {

					c.group( "Freehand Shape Debugging" );
					c.log( "<a data-shape=\"" + data + "\">Copy: SHAPE NAME</a>" );
					c.groupEnd();

				}

			};

			w.addEventListener( "copy", w.debugCopy );

		})( window, console );void(0);

	</script>


	<!-- I am the TEMPLATE for the stop-debugging bookmarklet. -->
	<script type="text/javascript-template" class="stop-debug-script">

		javascript:(function ( w ) {

			if ( w.debugCopy ) {

				w.removeEventListener( "copy", w.debugCopy );

			}

		})( window );void(0);

	</script>


	<script type="text/javascript" src="../../vendor/jquery/3.2.1/jquery-3.2.1.min.js"></script>
	<script type="text/javascript">

		// Setup the click-handler that presents the Start-Debugging bookmarklet.
		$( "a.start-debug" ).click(
			function handleClick( event ) {

				event.preventDefault();

				prompt(
					"Start Debugging Bookmarklet:",
					prepareBookmarkletContent( $( ".start-debug-script" ) )
				);

			}
		);

		// Setup the click-handler that presents the Stop-Debugging bookmarklet.
		$( "a.stop-debug" ).click(
			function handleClick( event ) {

				event.preventDefault();

				prompt(
					"Stop Debugging Bookmarklet:",
					prepareBookmarkletContent( $( ".stop-debug-script" ) )
				);

			}
		);

		// Setup the event-delegation for the Copy-Shape handler.
		$( "ul.shapes" ).on(
			"click",
			"a.copy",
			function handleClick( event ) {

				event.preventDefault();

				var anchor = $( event.target );
				var shapeData = anchor.data( "shape" );
				var shapeLabel = anchor.text();

				$( window ).one(
					"copy",
					function handleCopy( event ) {

						console.log( "Executing:", shapeLabel );

						// Unwrap the jQuery event.
						event = event.originalEvent;

						// In order to override the data that gets associated with the
						// "copy" event, we have to prevent the default behavior.
						event.preventDefault();

						// Store the proprietary shape data - this is what InVision
						// Freehand will use when processing the "paste" event.
						event.clipboardData.setData( "custom/binary", shapeData );

					}
				);

				// Execute the COPY command in order to invoke the copy handler above.
				// --
				// CAUTION: From my testing in Chrome and Firefox, I didn't actually
				// need to create any text selection for this event to take place. Your
				// mileage may vary in your browser.
				document.execCommand( "copy" );

			}
		);

		// Setup the event-delegation for the Delete-Shape handler.
		$( "ul.shapes" ).on(
			"click",
			"a.delete",
			function handleClick( event ) {

				event.preventDefault();

				deleteShape( $( this ).data( "id" ) );
				applyShapes();

			}
		);

		// Setup the form submission handler, which adds a new shape.
		$( "form" ).submit(
			function handleSubmit( event ) {

				event.preventDefault();

				var input = $( this ).find( "input.shape" );

				if ( input.val() ) {

					addShape( input.val() );
					applyShapes();

					input.val( "" );

				}

			}
		);


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


		// All of the shapes are stored in LocalHost (in order to make this demo a bit
		// more interesting). This is key that stores the ARRAY of shapes.
		var localStorageKey = "freehand_shape_copy_demo";

		// Load and render the shapes.
		applyShapes();


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


		// I add the given shape meta-data to the underlying collection.
		function addShape( outerHTML ) {

			var anchor = $( outerHTML );
			var shapes = getShapes();

			shapes.push({
				id: Date.now(),
				data: anchor.data( "shape" ),
				label: anchor.text()
			});

			// Sort the shapes alphabetically by label.
			shapes.sort(
				function operator( a, b ) {

					return ( a.label.toLowerCase() < b.label.toLowerCase() )
						? -1
						: 1
					;

				}
			);

			setShapes( shapes );

		}


		// I load and apply the shapes to the user interface.
		function applyShapes() {

			var shapes = getShapes();
			var shapesList = $( "ul.shapes" ).empty();

			shapes.forEach(
				function iterator( shape ) {

					var li = $( "<li />" )
						.appendTo( shapesList )
					;

					var anchor = $( "<a />" )
						.data( "shape", shape.data )
						.text( shape.label )
						.addClass( "copy" )
						.appendTo( li )
					;

					var deleteAnchor = $( "<a />" )
						.data( "id", shape.id )
						.text( "Delete" )
						.addClass( "delete" )
						.appendTo( li )
					;

				}
			);

		}


		// I delete the shape with the given ID.
		function deleteShape( id ) {

			var shapes = getShapes();

			shapes = shapes.filter(
				function operator( shape ) {

					return( shape.id && ( shape.id !== id ) );

				}
			);

			setShapes( shapes );

		}


		// I load the shapes from the underlying storage.
		function getShapes() {

			var rawShapes = localStorage.getItem( localStorageKey );

			return( rawShapes ? JSON.parse( rawShapes ) : [] );

		}


		// I persist the given shapes to the underlying storage.
		function setShapes( shapes ) {

			localStorage.setItem( localStorageKey, JSON.stringify( shapes ) );

		}


		// I prepare the given bookmarlet template for use in a bookmarklet URL.
		function prepareBookmarkletContent( scriptTag ) {

			var compressed = scriptTag
				.text()
				.replace( /[\r\n\t]+/g, "" )
				.replace( /^\s+|\s+$/g, "" )
			;

			return( compressed );

		}

	</script>

</body>
</html>

Because this page represents a user interaction workflow, it's a bit hard to get a sense for what it is actually doing - you should watch the video. But, the core component here is the "copy" event that copies the shape data back into the clipboard:

// Setup the event-delegation for the Copy-Shape handler.
$( "ul.shapes" ).on(
	"click",
	"a.copy",
	function handleClick( event ) {

		event.preventDefault();

		var anchor = $( event.target );
		var shapeData = anchor.data( "shape" );
		var shapeLabel = anchor.text();

		$( window ).one(
			"copy",
			function handleCopy( event ) {

				console.log( "Executing:", shapeLabel );

				// Unwrap the jQuery event.
				event = event.originalEvent;

				// In order to override the data that gets associated with the
				// "copy" event, we have to prevent the default behavior.
				event.preventDefault();

				// Store the proprietary shape data - this is what InVision
				// Freehand will use when processing the "paste" event.
				event.clipboardData.setData( "custom/binary", shapeData );

			}
		);

		// Execute the COPY command in order to invoke the copy handler above.
		// --
		// CAUTION: From my testing in Chrome and Firefox, I didn't actually
		// need to create any text selection for this event to take place. Your
		// mileage may vary in your browser.
		document.execCommand( "copy" );

	}
);

As you can see, I am using the document.execCommand( "copy" ) to trigger a Copy event in the external page. I am then using a Copy event binding to intercept that event, at which point I inject the persisted shape data into the event using the .setData() method. The clipboard then contains a shape that I can paste back into my Freehand session.

Obviously, this is a super brittle process that completely depends on the current implementation of the Freehand events and the event consumption. If the Freehand team changes anything, this whole demo breaks. But, for the time being, it was a really fun way to spend my Easter morning.

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

Reader Comments

15,848 Comments

@All,

I forgot to mention that one reason to try to save the "shape data" rather than just, say, keep an external file of vector graphics, is that the shape data scales well and everything else gets blurry -- even vector graphics copied from another source.

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