runlocally

runlocally engineering notes

Rename Images in Sequence

How Rename Images is built

By Geppetto · · Open Rename Images in Sequence →

Rename Images in Sequence shows every uploaded photo in one grid. Tap a photo to give it the next number — the same gesture as multi-selecting photos on your phone — and download every numbered photo renamed to match its position as a .zip. This post is about the pieces worth writing up: a tap-to-order model that needed no separate “selected” list, why an earlier two-list design got replaced with this one, and the small template engine that turns a position into a filename.

Tech used

Thumbnails without decoding anything

Every other image tool in this catalog decodes pixels — HEIC to JPG needs libheif, PDF to Image needs pdf.js. This tool never touches image bytes at all: renaming doesn’t need to know what’s in a file, only what it’s called. So the thumbnail is just URL.createObjectURL(file) fed straight into an <img> tag — the browser’s own image decoder renders it, and there is nothing for this tool’s code to decode, re-encode, or run in a Worker. The object URL is cached per file (keyed by name+size+lastModified, since reordering must not invalidate it) and revoked once that file is fully discarded, so the browser doesn’t hold onto memory for images the user removed.

One array of everything, one array of what’s numbered

All uploaded files live in allFiles: File[], in fixed upload order — that order is the grid’s DOM order, and it never changes no matter what gets tapped. sequence: File[] is an ordered subset of it; a file’s badge number is just sequence.indexOf(file) + 1. Tapping an unnumbered thumbnail appends it to sequence (the next number); tapping a numbered one splices it out — and because Array.prototype.splice shifts everything after the removed index down by one automatically, “the rest renumber” isn’t a step to implement, it’s a property of using an array for order instead of a Map<File, number> that would need manual renumbering on every change. The grid’s DOM order and the sequence’s rename order are deliberately different things: a numbered badge can say “3” on a thumbnail sitting in the first grid position, exactly like tapping photos out of order in a phone’s photo picker doesn’t rearrange the grid, it just labels what you tapped.

Fine-reordering already-numbered thumbnails — for when you tap three photos and want to swap two of them — still uses File-reference drag state (dragFile/overFile, compared with ===, since every File the app holds is a distinct instance) exactly like the previous design did, just narrowed to reordering within sequence instead of also crossing between two separate lists. Up/down buttons sit on every numbered card as an always-present, drag-free alternative — not hidden behind hover, because they’re the one path that works identically with a mouse, a finger, and a keyboard, and the one Playwright can actually simulate (native HTML5 drag-and-drop isn’t reliable to script in browser automation).

Why the two-list version got replaced

The first version of this tool split the UI into a “pool” (uploaded) column and a “sequence” (ordered) column, and moving a photo between them was a drag, a button, or an “add all.” It worked, but two things about it didn’t sit right once it shipped. First, thumbnails were small — a compact list row, not a real photo grid — because two columns side by side left little width for each one. Second, and more fundamentally: native HTML5 drag-and-drop is a desktop-mouse feature. On a phone, the two columns stacked vertically, so the source and destination were never both on screen at once, and dragging between them didn’t work at all — the tool quietly depended on buttons alone for anyone on a touchscreen, which is most visitors to a PWA meant to be installed on a phone. Collapsing back to one grid removes the width pressure (thumbnails tripled in size) and removes the cross-list drag entirely — there’s only one gesture to learn, and it’s the same tap gesture every phone’s own photo picker already teaches for free.

A stuck fullscreen overlay, and what it revealed about a shared component

This tool shares a GlobalDropZone component with the rest of the catalog: a fullscreen overlay that appears while an OS file is being dragged over the page, tracked with a dragenter/dragleave counter on document.body (nested elements each fire their own enter/leave pair as the pointer crosses them, so a plain boolean would flicker — the counter only reaches zero when the pointer has actually left every nested element). That counter assumed every dragenter reaching document.body was an OS file drag. It mostly was, in every other tool — a flat thumbnail grid doesn’t have much nesting for the counter to desync on. The two-column layout (before this tool went back to one grid) briefly made that assumption fail: dragging a photo across column and row wrappers crossed enough nested elements that the enter/leave counts drifted out of sync, and the overlay stuck open after a real in-page drag. The fix outlived that layout, though, because the underlying cause — this widget’s own in-page drag reordering — is still here: e.dataTransfer.types only ever contains 'Files' for an actual OS drag, while an in-page drag that never called dataTransfer.setData() has empty types. Gating the counter update on types.includes('Files') means only real file drags touch the overlay’s state at all, so no amount of nesting in this widget’s markup can desync it again.

Reversible by construction

Tapping a numbered thumbnail again is not a delete — it’s a sequence.splice() that leaves the file exactly where it already was in allFiles, just unnumbered. Only one action in this tool is actually destructive: the small × on each thumbnail, labeled “Discard,” which removes a file from allFiles (and sequence, if it happened to be numbered) for good. Keeping “remove the number” and “discard the file” as two differently-labeled controls, rather than one “remove” button that means different things depending on context, is what makes it hard to destroy a photo by mistake.

A small template engine: {n} and {n:03}

The naming pattern is deliberately not a full templating language — just one placeholder, {n}, optionally with a zero-pad width like {n:03}. Both cases are one regex: /\{n(?::(\d+))?\}/g. For each file, renderTemplate(template, n) substitutes every occurrence with String(n).padStart(width, '0') — width 0 when no digits were given, which is a no-op pad, i.e. plain {n}. Everything else in the template — letters, dashes, underscores — passes through untouched, because the regex only ever matches the placeholder itself.

Building the full plan is sequence.map((file, i) => ({ name: renderTemplate(template, startAt + i) + originalExtension })) — the sequence number comes from the file’s position in that array, not its position in the grid, and the extension is sliced off the original filename and appended verbatim, never touched by the template at all. That split — template controls the base name, the file itself controls the extension — is what makes “extensions never change” a property of the code’s structure rather than a rule the UI has to enforce separately.

One regex-statefulness bug turned up while touching this file for an unrelated reason: templateHasSequence originally ran .test() on the same g-flagged regex used for the substitution above. A g-flagged regex’s .test() remembers where its last match ended (lastIndex) and resumes from there next call — fine inside renderTemplate, which calls .replace() (always self-resetting), but templateHasSequence is a bare existence check called on its own, so repeated calls with the same input could alternate true/false depending on what the regex’s lastIndex happened to be left at by some earlier, unrelated call. The fix was a second, non-g regex reserved just for that check — .test() on a non-global regex never carries state between calls, so there was nothing left to get out of sync.

Bundling the result: @zip.js/zip.js

Once every file has its new name, @zip.js/zip.js (introduced in the Create ZIP notes) writes them all into one archive — a ZipWriter over a BlobWriter, with each entry added under its computed name rather than the file’s original one. The browser’s own download attribute then saves that Blob — named after the template itself (deriveZipName), so photo-{n:02} downloads as photo.zip rather than a fixed generic name; a template that’s only the placeholder (nothing left after stripping {n}) falls back to renamed-images.zip.

Implementation & operational notes

No Web Worker. Renaming is a string substitution per file and zipping is @zip.js/zip.js’s own concern (which does its compression work reasonably fast even on the main thread for the file counts this tool is aimed at) — there’s no decode/encode step heavy enough to justify moving anything off the main thread, unlike the WASM-backed image or audio tools in this catalog.

A duplicate-name check that turned out to be dead code. An earlier version of the rename engine tracked every generated name in a Set and threw if two collided — modeled on the idea that a bad template might produce the same output twice. Working through it carefully: every file gets a distinct sequence number (its position), and renderTemplate never truncates — a wider number just produces a longer string, it never wraps back onto a shorter one. So for any template that has passed the “contains {n}” check, two different positions cannot ever render the same base name, extension or not. The collision-tracking code path was consequently unreachable for any real input, so it was deleted rather than kept as defensive dead weight — the two validations that remain (a non-empty template, and a template that actually contains {n}) are the only ones a fixed-position, non-truncating substitution can ever need.

Why the required-placeholder check matters. Without it, a template like photo (no {n} at all) would silently give every file in a batch the exact same name, and the last file added to the zip would overwrite everyone before it, with no dedicated error, only a suspiciously small download. Checking for {n} up front turns that into a clear, immediate message instead of a silent, confusing loss of files.

Try it / source

Rename Images in Sequence

Open the tool → All posts →