Why This Project?
Every chapter so far has stored data in the browser. This chapter introduces a new idea: getting data out. The File Download API lets you create a file on the fly from a JavaScript string and trigger a browser download — no server, no file system access, no libraries. Just a Blob, a URL, and an anchor click.
The note taker also introduces keyboard shortcuts via addEventListener('keydown'), structured tagging as a data design pattern, and contenteditable for inline text editing — three patterns that show up in nearly every real productivity tool.
File Download API
Create a Blob from a string, wrap it in a temporary object URL with URL.createObjectURL(), attach it to a hidden <a> tag, click it programmatically, then revoke the URL. That's a download with no server.
keydown Events
document.addEventListener('keydown', e => {...}) intercepts every keystroke. Check e.key, e.metaKey, e.ctrlKey to build keyboard shortcuts that work alongside the UI buttons.
Structured Tagging
Each note has a tag field (action / decision / question / idea / note). This turns a flat list into a filterable, exportable structured dataset — without a database.
contenteditable
Add contenteditable="true" to any element to make it inline-editable. Capture the new value on blur or keydown Enter and write it back to your data array.
The AI Angle for This Chapter
This chapter has multiple export formats. That's a good AI challenge — generating three different string formats (plain text, Markdown, actions-only) from the same underlying data array. The key skill is telling AI exactly what structure the output should follow, not just "export the notes."
Format specs beat vague requests. Instead of "export as Markdown," prompt: "Group notes by tag. For each group, write an H2 header, then a bullet list. Action items use GitHub-style checkboxes. Include timestamp in bold before each note text." That's a spec — and AI can execute a spec precisely.
Before writing any export function, write out the exact output format you want by hand. Paste one real example note through the format and show AI what the output should look like. It will get it right on the first pass.
Before You Build — Map the Data
This app's core is a single array of note objects. Design the data structure before touching the UI:
- What fields does a note need? At minimum: id, text, tag, and timestamp. What else might be useful at export time?
- Where does the tag live — in the note object or in the DOM element? What happens if you only store it in the DOM?
- How will you handle inline editing? When the user clicks a note and edits it, where does that change get written?
- The filter only affects what's displayed — not what's stored. How do you make sure filtering never deletes data?
- Design the Markdown export format on paper first. What should an actions section look like vs. a decisions section?
Good data design makes every feature easier. If your note object has all the fields it will ever need from the start, none of your export or filter functions require workarounds.
Build It
Scaffold the two-pane layout and note entry box
The layout is a wide main pane (note entry + list) and a narrow sidebar (summary counts, filter, export, shortcuts). The entry box has a tag toolbar above the textarea and an Add button below.
Build a two-column layout: main pane (flexible) and sidebar (340px).
Main pane contains:
- A meeting meta section: title, date/time, attendees, context inputs
- An entry box: tag buttons toolbar (Note/Action/Decision/Question/Idea),
a textarea for note text, a footer with keyboard hint and Add button
- An empty notes list div
Sidebar contains: summary count cards (Actions/Decisions/Questions/Ideas),
filter buttons, export buttons, keyboard shortcut reference.
Color palette: #111 background, #e00 red, tag colors coded by type.
Build the note data model and render function
Notes live in a JS array. Each has: id (Date.now()), tag, text, time (formatted HH:MM), ts (raw timestamp), done (bool). The render function rebuilds the entire list from the array — newest first — and updates the sidebar count badges.
Write addNote() that:
1. Reads the textarea value and current selected tag
2. Creates a note object: { id, tag, text, time, ts, done: false }
3. Pushes it to the notes array
4. Clears the textarea and calls renderNotes()
Write renderNotes() that:
1. Filters notes by currentFilter ('all' or a tag name)
2. Reverses the visible array (newest first)
3. Builds HTML for each note: tag pill, editable text, timestamp, delete button
4. Action items also get a toggle-done checkbox button
5. Updates count badges in the sidebar for each tag type
Add keyboard shortcuts
Wire up document.addEventListener('keydown') for: Cmd/Ctrl+Enter to submit the note, Escape to clear the textarea, and Ctrl+1 through Ctrl+5 to switch the active tag without touching the mouse.
Add a single keydown listener on document:
- (e.metaKey || e.ctrlKey) && e.key === 'Enter' → call addNote()
- e.key === 'Escape' && activeElement is textarea → clear textarea
- e.ctrlKey && e.key === '1' → setTag('note')
- e.ctrlKey && e.key === '2' → setTag('action')
- e.ctrlKey && e.key === '3' → setTag('decision')
- e.ctrlKey && e.key === '4' → setTag('question')
- e.ctrlKey && e.key === '5' → setTag('idea')
Use e.preventDefault() on shortcuts that conflict with browser defaults.
Build the File Download API exports
Three export functions — plain text, Markdown, and actions-only — each builds a string from the notes array, wraps it in a Blob, creates a temporary object URL, clicks a hidden anchor, and revokes the URL. No server, no file system permissions.
Write exportMd() that:
1. Builds a Markdown string grouping notes by tag
2. Each group has an H2 header and a bullet list
3. Action items use GitHub checkbox syntax: - [ ] or - [x]
4. Each bullet includes timestamp in bold: **[09:02]** Note text
5. Adds meeting metadata (title, date, attendees) at the top
For the download itself:
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'meeting-notes.md';
a.click();
URL.revokeObjectURL(url);
Add localStorage persistence and inline editing
Save the entire state (notes array + meeting metadata) to localStorage on every change. On load, restore from storage or load demo defaults. Add contenteditable to note text elements so users can click and edit directly in the list.
Add saveData() that JSON.stringifies { title, date, attendees,
context, notes } into localStorage under key 'it_ch05_notes'.
Call saveData() after every add, delete, toggle, and text edit.
For inline editing:
<div contenteditable="true"
onblur="updateText(id, this.textContent)"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){
event.preventDefault(); this.blur(); }">
The blur fires updateText(id, newText) which finds the note
in the array by id, updates note.text, and calls saveData().
Concepts You Just Used
new Blob([string], { type })— create an in-memory file from any stringURL.createObjectURL(blob)— generate a temporary browser URL pointing to that blobURL.revokeObjectURL(url)— release the memory after the download firesdocument.addEventListener('keydown', e => {...})— intercept all keyboard input globallye.metaKey/e.ctrlKey/e.key— identify modifier keys and the specific key pressedcontenteditable="true"— make any DOM element inline-editable by the useronblur— fire when focus leaves an element; the right moment to capture edits- Structured tagging — storing a
tagfield on each data object enables filtering, grouping, and format-specific export without extra state
The Blob download pattern is a superpower. Once you know it, you can export anything from a browser app — CSV, JSON, SVG, Markdown, plain text — without ever touching a server. It's the same technique behind "Export" buttons in Notion, Figma, and Airtable.
Reflect
- What's the difference between
URL.createObjectURL()and adata:URI? When would you use each? - Why do you call
URL.revokeObjectURL()after the download? What happens if you don't? - The filter only hides notes — it doesn't delete them. How is that different from how most filtering systems work in databases vs. in-browser?
- Inline editing with
contenteditableis powerful but has quirks. What happens if the user pastes rich text (bold, links) into an editable div? How would you prevent that? - This app stores one meeting at a time. What would the data model look like if it stored a history of multiple meetings?
The tools you used here — Blob, Object URL, keydown, contenteditable — are the building blocks behind every browser-based productivity app. You didn't use a library. You used the platform.
Go Further
📄 Meeting History
Store multiple meetings in localStorage as an array keyed by date. Add a "Past Meetings" sidebar panel to browse and reload previous sessions.
🔍 Search
Add a search input that filters the visible notes in real time by matching the note text. Highlight the matched substring in the result.
📋 Copy to Clipboard
Add a "Copy as Markdown" button using navigator.clipboard.writeText() — no file download, just straight to the clipboard for pasting into Slack or Notion.
🕐 Meeting Timer
Add a running elapsed-time counter that starts when the page loads and is stamped into the export header. Bonus: add a "time per section" marker.
👥 Attendee Tracker
Parse the attendees field into a list. Let each note be assigned to a specific attendee. Filter by person and export an individual action list per attendee.
📤 Export to Email Draft
Build the export string and open a mailto: link with the meeting summary in the body — pre-addressed to the attendees field.
Brand Palette Generator
Pick a base color, generate a full brand palette with tints, shades, and complementary colors, and export as CSS variables or a swatch sheet. You'll learn color math, HSL manipulation, and dynamic CSS generation.
Start Chapter 06 →