log 019

Dev Log: Timelines

I'm building a DAW in the browser using the web audio API. If you have no idea what that means no worries, here is a picture that will help.

There is a LOT that goes into building a DAW, but one of the foundations is the Timeline. The area where you actually put audio clips and arrange the song along. Timelines are not unique to editing music though! They are also a fundamental building block in video editing, animations, and all sorts of interesting projects.

BUT there is no chance I can cover everything in this log. Let's walk through the anatomy of a timeline first, and then we can breakdown how to build the three pieces that we need for any timeline based project we might build.

  1. Navigator
  2. Zoom Window
  3. Projection

The Navigator represents the full length of your content. Whether that is a song, a video, or a random grid of squares. The whole use case for a timeline is when you are working with data that cannot be displayed all at once and you want to provide a visual form of navigating the content and controls for changing how dense the content you are currently displaying is.

The Zoom Window is the control mechanism for doing the actual work of navigating and changing displayed content density.

The Projection displays an editable window of the content that is currently the target of the zoom window. As the zoom window changes location or size so does the projection of that data into this window.

Let's start by getting all of the UI elements we need on the screen. Then we will go step by step to get them working.

export function Timeline() {
  return (
    
) } function Navigator() { return (
{/* Zoom Window */}
{/* Left handle */}
{/* Right handle */}
) } function Projection() { return (
{/* Content goes here */}
) }

Normally I would just put everything into a single component at this point, buuut I know how much code we are about to be adding so I saved us some trouble and went ahead and split things up.

This line is important:

overscrollBehaviorX: 'none'

This avoids the default behavior in macOS where scrolling left on trackpad moves you to the previous url.

Now let's add the state needed to make our static timeline something we can control.

Our core timeline state consists of 3 values.

  • Content length: this is the full length of the content in the timeline
  • View: this is an object that represents the amount of the content we are currently editing
  • Min: This is the smallest length either our content or view can be

For all of our data we will be building out modules that define the types and operations available for it. This keeps our core logic framework agnostic.

Let's start with our Timeline type.

// timeline.ts
import type { Numeric } from './numeric'
import * as S from './span'

export type Timeline = {
  size: A
  view: S.Span
  min: A
}

Then let's add a function that we will use when we first create our Timeline state. This function is important because it's how we make guarantees that our view is always within the bounds of the min length and the full length of our content.

Now we can use it in our timeline component.

We have now ran into our first gotcha when working with Timelines. How do you render a content that is 20000 pixels? All the numbers we are storing right now are content pixels. They represent the literal size of the data. That means if we tried to take them and use them directly to render to the dom everything would be WAY too big.

All of this data needs to be scaled based on the available room we have in our DOM elements. How do we do that?

Well if we only have 1800px available in the DOM that means our 20000 = 1800. What does that mean for our min value? And what about the positions along it where our zoom window is located? Well, let's take a little stroll back to middle school algebra class.

In school this was called cross multiplying or proportional fractions. However, we are going to call this projections in our context and build a module around it. Its focus is on converting content pixels to screen pixels and vice versa.

Alrighty, we have the conversion functions, but if you take a look at the implementation there are two new pieces of data we don't have in our implementation yet.

The scaleFor function is how we get the first piece of data we need so that we can call the to/from functions, but to get the scale we need the container width. For that we need to go to the DOM and grab it from there directly once it renders.

To do that we can use this handy hook. It allows you to track an element's size and make sure it stays updated even if the element resizes due to outside factors like browser width change.

Then we use this hook to calculate our projections so that our zoom window can be based off our actual timeline state instead of static values.

Now our zoom window is based on our actual state values. If those change then our zoom window will change position and size too….but we don't have any way to make those changes yet.

We need to add some actions that you would expect out of a timeline navigator:

Let's take these one by one starting with the event that is attached to the element highest in the tree (snap) and add the DOM events and listeners we need.

One common thread between all of these actions is being able to get the position of the cursor within the navigator. Since the timeline runs only along one dimension we are grabbing the cursor position as an offset from the left side of the navigator along the X axis.

With that helper we can kick things off by adding the ability to pan a timeline in the core modules. This requires a change in our core files. Panning is moving our view around within the bounds of the timeline. Since our view is a Span type, we can extract the Span type into its own module and handle the move logic there.

Then inside of our Timeline module we will move our view and run it through our normalize function to enforce that the changes we made fall within the bounds of the timeline.

Now that our core functionality exists, let's add our event handler. We are going to do this in two layers. Layer one is the react layer. It handles checking for refs, and any event specific handling that needs to be done.

The other layer is for calculating the delta required from the pan function in the timeline module and returning the adjusted timeline. We want to make the offset a parameter. For snapping we want to use a hard coded value representing the center point of the zoom window, but for other interactions like panning we will have a dynamic pointer offset across the zoom window.

Once you have snap setup the next interaction to tackle is panning the zoom window around the timeline.

However, there are two big differences in how we handle panning.

The first difference means we need to make sure events do not propagate up the tree using stopPropagation

We need pointer capturing, and we need data that gets passed around the full event lifecycle.

As for what data is needed across the lifecycle we will need to track the following:

Our ref data will go in a new model we can call interaction, and we can set up a type for the data we need while panning.

Here is the full implementation of the pan events:

Time for our next set of events. These are a pair of events for resizing the zoom window by dragging on an edge of the window.

The final piece of the puzzle is the ability to change the zoom window size from the center point. The way we are going to approach this is sharing the pointer event with the panning functionality, but with two adjustments.

The reason we want to require alt key is that no human can perfectly drag a mouse along a single axis. There are many options for how to handle differentiating between pan and zoom, but I like the clarity of having the alt key activate the alternate functionality.

To track when the alt key is being held we can use a simple hook to update pointer style:

Projection Sync

The last piece of the puzzle is syncing the Projection component with the timeline state. The Projection is a scrollable container that shows the content within the current view. When the navigator changes the view, the projection should scroll to match, and when the user scrolls the projection, the timeline state should update.

Notice the bidirectional sync here. We have state → DOM (updating scroll position when timeline changes from navigator) and DOM → state (updating timeline when user scrolls the projection). The suppressScrollEventsRef prevents infinite loops by ignoring scroll events triggered by our own state updates.

And there we have it! A fully functional timeline system with navigator, zoom window, and projection. You can check out the live demo to see it in action.

I hope you enjoyed

There is a lot more coming...

If you want to get updates when I publish new guides, demos, and more just put your email in below.