Update: I’ve improved on my technique! Find the updated blog post here.
Ever seen this (awesome) LifeHacker post on turning your browser window into a quick edit notepad?
TL; DR: I put together a script that lets you save the notes you write in your browser-scratchpad. Find it here.
Simply put, data URIs allow content creators to embed small files inline in documents [source: mdn]. Most notably, Google Images renders their image searches as data URIs (just go search for something in images and inspect element on the results).
Since data URIs are just small files, we can also paste them into the browser URL bar and expect the browser to handle them in a certain way. For example, paste a data URI from google images into your URL bar and the image will show up. Paste a small PDF file in there and it will download. Paste the following:
data: text/html, <html contenteditable>
and get an HTML page that is “contenteditable” meaning you can jot down notes and/or copy and paste other rich text formats straight into your browser window.
Awesome!
…but there’s an issue. Your content won’t save.
Ever.
And as it turns out, it is actually impossible to save content locally to your browser with this technique.
You can probably do an AJAX call to a simple data store and dump your info, but you cannot ever access localStorage via a data URI.
I thought about this for a long while — I really wanted to be able to temporarily store some data in this generic notes tab to protect against cases like accidentally refreshing or hitting the back button. And also I had a hunch this should be possible, I wanted to verify this.
Here were the requirements I applied for this app:
- NO external services to save content. So no dropbox integration or drive integrations, etc.
- Must use window.localStorage somehow to store data.
- Be as lean as possible. (ie: not too many external scripts or dependencies).
- Should work offline.
- Two buttons: a ‘Save’ button to save content and a ‘Load’ button to load already saved content. That’s it.
So naturally the hard part was trying to come up with a way around the localStorage problem.
Here’s why: if you open up a data: text/html, <html contenteditable> page and try to set or retrieve data from localStorage, you get hit with the following:
VM142:1 Uncaught DOMException: Failed to read the ‘localStorage’ property from ‘Window’: Storage is disabled inside ‘data:’ URLs.
Ok, fine. That makes sense. But what if we embed an iframe inside this contenteditable HTML tag and try to access localStorage there?
Same error.
What if I embed an iframe and inside of that embed another iframe and try it? In other words, how deep does this rabbit hole go?
Pretty damn deep. This approach will not work.
After this dead end, I stumbled on to my first big breakthrough:
What if I spawned a new tab, complete with a legit domain, and tethered it to my data URI app?
The main and obvious advantage to this approach is that I’ll be able to access localStorage without a hitch.
The main drawback boils down to communication: even if we use this tethered tab to save content, how is that content relayed back to the data URI tab for the user? Also, does this mean that the user will have to always have two tabs open to use the app? What happens if the user is offline? Will this work?
The first question is more pressing, so let’s address that.
To solve the problem of cross window communication, I opted to look into the window.postMessage API which allows for cross tab data sharing given that the origin of both windows are the same (ie: they are from the same domain).
This did not work out too well.
This approach initially failed for the same reason that the localStorage issue broke down: data URIs have a domain of `null`, which means that data cannot be shared from the data URI tab to any other window.
BUT! Running a postMessage with the reference to a window in hand circumvents this problem. In other words, even if this fails on the data URI page:
window.postMessage('Hello, Wrold!', 'localhost:4011'); // lol #fail
this works:
// select the iframe
let frame = document.querySelector('.iframeClass').contentWindow;// send over a message
frame.postMessage('Hello, Frame!', '*');
In other words, if we had a `message` listener in the iframe, it would pick up the ‘Hello, Frame!’ message.
BUT, trouble in paradise, yo — as we saw before, having an iframe inside the data URI page is useless.
Ok. Sure. But surely we can take this technique and apply it to say the `window.open` method. The `window.open` returns a reference to the newly opened window, meaning we could still call the postMessage from that newly returned object…right?
Right! Except not really.
Chrome automatically blocks the popup and you can’t tell it to make a special case for our data URI because (you guessed it!) the data URI lacks a domain which is how Chrome tracks all this.
Now we could disable popups completely on Chrome for this to work — but that would just compromise the browser’s security and it would require the user to do something more than just opening up the data URI. For these reasons, this method alone cannot work.
The second key insight that helped me solve this problem was this:
Opening a new page with `target=”_blank”` will store a reference called `window.opener` in the newly opened window.
And that points to — again, you guessed it — the data URI window. What this means is I can do something like this:
// in a window opened from the data URI page with target="_blank"
window.opener.postMessage('data_here', '*');
And in the data URI window, I can access the ‘data_here’ by listening to the generic window message event:
// in the data URI window
window.addEventListener('message', (e) => {console.log( e.data ); // should have 'data_here' stored
})
Ok now we’re cooking!
The `window.opener.postMessage` technique paves the way for us to be able to load in saved content to our data URI window from localStorage. Great.
But how do we actually save to localStorage?
The solution to this isn’t as clean, but it works. There’s really no good way to post a message to the tethered window due to the same origin restriction. To get around this, I opted to listen for any updates to the data URI window’s content. (Updates can be triggered by the user typing, for instance).
When an update occurs, I take the innerHTML, base64 it with the `window.btoa` method, and append the result as a URI encoded query string. This new href is then pushed to the ‘Save’ button’s href attribute so when the user clicks save, we open up a tethered window that checks for a query string and if it exists, pushes it to localStorage.
In both cases when a tethered window is opened, once the main operation — postMessaging back to the data URI page for a content load OR saving to localStorage for content save — is completed, I kill the window. This ensures that for the most part, only one window, the data URI window, is open and active.
And finally, to prevent the user from losing content by mistake, I keep track of the state of the content in the window. If there is unsaved content, I freeze the navigation with a confirm statement before canceling the navigate away or, based on user response, allow it to go through. The fantastic `window.onbeforeunload` makes this feature possible.
And that’s it! That’s the entirety of the technique. I put all this together into a handy webpage with a button you can drag over to your bookmarks bar. Visit the site here. Only tested on Chrome, so results may vary (but expect it to work on Chrome).
That’s it for now folks. Happy coding!