Photo-editor | Debugger

There hasn’t been a lot of advancement in the ease of use of photo editing tools, and the barrier remains fairly high. Generative AI doesn’t do as much for photo editing as it does for full blown photo generation. So while the new iPhone and iOS launches have all been about Apple Intelligence, the feature that I’ve loved is the new style edit tool in the Photos app. Style edit is the much needed improvement in editing UX. It’s akin to Prometheus making fire accessible to mere mortals.. well mortals who own an iPhone 16. It’s a 2-dimensional slider that combines something they call a palette, and a general 3-channel curves tool. After testing a few styles I’ve concluded that what they call palette is just a look-up-table (LUT). Something colorists have used for decades to create a consistent visual language through color scheme. It’s an incredibly fast way to alter the look and feel of an image, but I digress.

Why do I care so much?

When Douglas Engelbart first introduced the mouse in ‘68 it opened UX possibilities that are still the primary mode of interacting with a computer. Multi-touch screens ushered in a new era of user experiences that were not possible before, eg. doomscrolling. Now I can’t claim that the 2D slider is going to change our world, but for lazy photographers like myself it might be the best compromise between unedited photos and a cohesive visual language.

What’s annoying though is that Style editor is paywalled behind the latest version of the iPhone, and it only works on the photos taken with the iPhone 16. Bummer, I wonder if I can just write a web version.

This is what it looks like:

Let’s bring it to the web, shall we?

As I like to have fun and not burning out on side-projects, I decided to build the toy first, that is the 2D slider component.

Rendering the dots

I start off by creating a dot grid. CSS grid made this trivial once I decided on the number of rows and columns. Once the cells are in place, I render a circle in the middle of each cell.

I wanted to make the size of each dot react to the distance from the pointer. And also ensure that the effect dissipates quickly leaving any dots outside of a certain distance un-affected. As I want the strength of the effect to decrease sharply I can make it proportional to the inverse of some power of the distance. Square should work, right?

Calculating distance of dots

To calculate the distance I need the position of the cell’s center where the dot is rendered. Once the cell is rendered and its layout calculated, I use getBoundingClientRect to get its position and size. Position of the center is just: x = (rect.left + rect.width / 2) y = (rect.top + rect.height / 2)

To validate I can render the actual value of the square distance inside each cell.

squareDist = (dot.x - touchPosition.x)^2 + (dot.y - touchPosition.y)^2

Currently this gives me actual pixel distance, but what I really want is a relative distance grounded in the bounds of the container, in a range [0, 1]. For instance if I move the pointer to a corner of the container, the diagonally opposite corner should be at a distance of 1.

The normalized square distance then becomes: squareDist / (diagonal length)^2 This ensures that the values we get are always under 1.

Frame of reference

The diagonal length comes from the container. To get the bounds of the container I use getBoundingClientRect. It provides the left, top, height, and width of the component. From the touch events I already have the position of the cursor, so I can determine the cursor position relative to the container. x = touchPosition.x - rect.left y = touchPosition.y - rect.top normalized(x) = (touchPosition.x - rect.left) / rect.width normalized(y) = (touchPosition.y - rect.top) / rect.height Then clamping the value to (0, 1) would give me the position inside the container.

Dot sizes

Now that I have the normalized distance, I can use that to bump the size of each dot based on an arbitrary constant multiple: SCALE_MULTIPLE * (1 - normalizedSquareDist) But before that I want to debug the normalized distance values. A fairly intuitive way to do this is to use hsl color values. And I replaced the dots with actual clampedScale values with two precision points.

CSS makes the first part trivial: hsl(${(1 - normalizedSquareDist) * 360}, 100%, 60%). As hue values range between [0, 360], and normalizedSquareDist ranges between [0, 1], hence (1 - normalizedSquareDist) * 360 becomes [360, 0] and gives me the whole rainbow.

Check out the interactive version here.

const normalizedSquareDist = clamp(
  squareDistFromTouchPoint / squareDiagonalLen,
  0,
  1,
);

const clampedScale = 1 - normalizedSquareDist;

// ...

<span
  style={{
    color: `hsl(${(1 - normalizedSquareDist) * 360}, 100%, 60%)`,
  }}
>
  {clampedScale.toFixed(2)}
</span>

Interesting results

Now if I add scale multiple on top of the square distance and scale the text content, I get an interesting result. The numbers are almost unreadable closer to the cursor, and it affects almost the entire grid.

Interactive

// normalized
  const normalizedSquareDist = clamp(
    squareDistFromTouchPoint / squareDiagonalLen,
    0,
    1,
  );

  const clampedScale = clamp(
    (1 - normalizedSquareDist) * SCALE_MULTIPLE,
    SCALE_MIN,
    Number.POSITIVE_INFINITY,
  );

// ...

<span
  style={{
    // ...
    transform: `scale(${clampedScale})`,
  }}
>
  {(clampedScale).toFixed(2)}
</span>

Scale multiple with faster attenuation

To reduce the radius of the scaling, I can make the initial fraction smaller i.e. 1 - normalizedSquareDist * <some-multiple>. This causes the scaling to drop sharply creating a blob quite similar to what I initially set out to make.

Interactive


// normalized
const normalizedSquareDist = clamp(
  squareDistFromTouchPoint / squareDiagonalLen,
  0,
  1,
);

const clampedScale = clamp(
  (1 - normalizedSquareDist * 5) * SCALE_MULTIPLE,
  SCALE_MIN,
  Number.POSITIVE_INFINITY,
);

// ...

<span
  style={{
    // ...
    transform: `scale(${clampedScale})`,
    // ...
  }}
>
  {clampedScale.toFixed(2)}
</span>

Skittles

Once I had the attenuation effect working, I wanted to turn the “debugger” off and go back to rendering the dots. This is my favorite version, I wonder what the cat thinks.

Interactive

const clampedScale = clamp(
  (1 - normalizedSquareDist * ATTENUATION) * SCALE_MULTIPLE,
  SCALE_MIN,
  Number.POSITIVE_INFINITY,
);

<span
  style={{
    // ..
    transform: `scale(${clampedScale})`,
  }}
  className={
    'pointer-events-none dark:bg-white bg-black rounded-full w-4 h-4'
  }
/>

Monochrome, just like Steve wanted

I simplified the effect a little bit so I don’t get distracted by my own UI. This is supposed to be a photo editor remember! To add just a little bit of visual interest I applied similar but simpler math to the opacity.

Interactive

const opacity = isMouseDown
  ? clamp(1 - normalizedSquareDist, OPACITY_MIN, OPACITY_MAX)
  : OPACITY_MIN;

const clampedScale = clamp(
  (1 - normalizedSquareDist * ATTENUATION) * SCALE_MULTIPLE,
  SCALE_MIN,
  Number.POSITIVE_INFINITY,
);

<span
  style={{
    opacity,
    transform: `scale(${clampedScale})`,
  }}
  className={
    'rounded-full w-4 h-4'
  }
/>

What’s next?

This is only the beginning, I haven’t built a renderer yet. I’ll write about that next time as it’s too late and I wanna go back to reading my book.