Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ Resize, compress and convert images for websites, in the browser. Files are proc

## Using it

Drop images or folders on the page, choose files, or paste. Then pick an output:
Drop images or folders on the page, choose files, or paste. They're listed, and nothing is made until you press **Optimize**. Pick an output first:

- **CMS master** (the default): one high-quality file per image, no wider than a maximum width. For a CMS or site builder that makes its own sizes (Wagtail, Payload, Webflow, Framer, …), so the stored original isn't needlessly large.
- **Responsive set**: several widths of each image in modern formats, plus `<picture>` markup to paste into your site. For sites you build yourself.
- **CMS master**: one high-quality file per image, no wider than a maximum width. For a CMS or site builder that makes its own sizes (Wagtail, Payload, Webflow, Framer, …), so the stored original isn't needlessly large.

The settings are locked while images are being optimized; _Stop_ ends the run. Changing a setting afterwards doesn't run anything: press Optimize again. If you switch back to settings an image was already optimized with, its files come back without another run.

Download everything as a ZIP, or single files from a row. In Chrome and Edge, _Save to folder_ writes everything straight into a folder you pick, such as your site's `public/images`. Tap a thumbnail to compare an output with the original: drag the divider and switch between fit and actual pixels.

Expand Down Expand Up @@ -54,7 +56,7 @@ Reads JPEG, PNG, WebP, AVIF, GIF (first frame) and TIFF. Other formats are hande

Images are decoded, resized and encoded on your device, in the page. The Content Security Policy (`connect-src 'self'`) stops the page from sending data to any other server, and there are no analytics, trackers or third-party scripts. The font is served from the same site.

Your settings (output, role, quality, maximum width, path, markup, placeholder) are kept in your browser's local storage. A service worker keeps a copy of the app in the browser's cache, so it works offline; it only ever fetches the app's own files.
Your settings (role, quality, maximum width, path, markup, placeholder) are kept in your browser's local storage. The output isn't: each visit starts on CMS master. A service worker keeps a copy of the app in the browser's cache, so it works offline; it only ever fetches the app's own files.

## Development

Expand Down
9 changes: 9 additions & 0 deletions e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ for (const colorScheme of ['light', 'dark'] as const) {
// A failed row, for the contrast of its message.
{ name: 'broken.heic', mimeType: 'image/heic', buffer: Buffer.from('not a HEIC') },
])
// Listed, waiting for Optimize.
await expect(page.getByRole('cell', { name: 'Ready' })).toHaveCount(2, { timeout: 30_000 })
expect(await violations(page), 'ready').toEqual([])

await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 2 images' }).click()
// While processing: the progress bar needs a name.
await expect(page.getByRole('progressbar', { name: 'Processing photo.jpg' })).toBeVisible()
expect(await violations(page), 'processing').toEqual([])
Expand All @@ -42,6 +48,7 @@ test('focus stays on the list when rows are removed', async ({ page }) => {
{ name: 'b.jpg', mimeType: 'image/jpeg', buffer: await makeJpeg(page, 600, 400) },
{ name: 'c.jpg', mimeType: 'image/jpeg', buffer: await makeJpeg(page, 600, 400) },
])
await page.getByRole('button', { name: 'Optimize 3 images' }).click()
await expect(page.getByText(/^3 done/)).toBeVisible({ timeout: 90_000 })
const remove = (name: string) => page.getByRole('button', { name: `Remove ${name}` })

Expand Down Expand Up @@ -73,6 +80,8 @@ test('copying is announced', async ({ page, context }) => {
mimeType: 'image/jpeg',
buffer: await makeJpeg(page, 600, 400),
})
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
const announced = page.getByRole('status').filter({ hasText: 'Copied to the clipboard.' })

Expand Down
68 changes: 62 additions & 6 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context })
mimeType: 'image/jpeg',
buffer: await makeJpeg(page, 1000, 600),
})
await expect(page.getByRole('radio', { name: 'CMS master' })).toBeChecked()
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()

// Content role, 1000 px source: 400, 800 and 1000 px, as AVIF + JPEG
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
Expand All @@ -33,7 +36,7 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context })
'<img src="/images/hero-shot-1000w.jpg"',
)

// JSX markup: React prop names; the choice is remembered
// JSX markup: React prop names; the choice is remembered, unlike the output
await page.getByLabel('Markup').selectOption('JSX')
await expect(page.locator('pre')).toContainText(
'<source type="image/avif" srcSet="/images/hero-shot-400w.avif 400w',
Expand All @@ -43,9 +46,11 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context })
expect(await page.evaluate(() => navigator.clipboard.readText())).toMatch(
/^\{\/\* Hero Shot\.jpg \*\/\}\n<picture>/,
)
expect(
await page.evaluate(() => JSON.parse(localStorage.getItem('image-optimizer:settings')!).markup),
).toBe('jsx')
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('image-optimizer:settings')!),
)
expect(stored.markup).toBe('jsx')
expect(stored).not.toHaveProperty('mode')

// average-colour placeholder, as an inline style
await page.getByLabel('Placeholder').check()
Expand Down Expand Up @@ -76,12 +81,21 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context })
])
expect(download.suggestedFilename()).toBe('images.zip')

// Switching mode re-processes; the row stays expanded.
// Switching mode runs nothing until Optimize; then the row opens on the new files.
await page.getByRole('radio', { name: 'CMS master' }).click()
await expect(page.getByRole('cell', { name: 'Ready' })).toBeVisible()
await expect(page.getByRole('progressbar')).toHaveCount(0)
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await expect(page.getByText('hero-shot.jpg', { exact: true })).toBeVisible()
await expect(page.getByText('hero-shot-400w.avif', { exact: true })).toHaveCount(0)

// Switching back brings the responsive set back, without another run.
await page.getByRole('radio', { name: 'Responsive set' }).click()
await expect(page.getByRole('cell', { name: 'Done' })).toBeVisible()
await expect(page.getByText('hero-shot-400w.avif', { exact: true })).toBeVisible()
await expect(page.getByRole('progressbar')).toHaveCount(0)

expect(errors).toEqual([])
})

Expand All @@ -92,14 +106,18 @@ test('detects a flat graphic and gives it lossless WebP + PNG', async ({ page })
mimeType: 'image/png',
buffer: await makePng(page, 1800, 900),
})
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await expect(page.getByLabel('Role for logo.png')).toHaveValue('Graphic')
await page.getByRole('button', { name: 'logo.png', exact: true }).click()
for (const name of ['logo-800w.webp', 'logo-800w.png', 'logo-1600w.webp', 'logo-1600w.png'])
await expect(page.getByText(name, { exact: true })).toBeVisible()

// overriding the role re-processes just this image
// overriding the role needs another Optimize, for just this image
await page.getByLabel('Role for logo.png').selectOption('Content')
await expect(page.getByRole('cell', { name: 'Ready' })).toBeVisible()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await expect(page.getByText('logo-1600w.avif', { exact: true })).toBeVisible()
})
Expand Down Expand Up @@ -146,6 +164,8 @@ test('saves into a folder, leaving a foreign manifest.json alone', async ({ page
mimeType: 'image/jpeg',
buffer: await makeJpeg(page, 1000, 600),
})
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await page.getByRole('button', { name: 'Save to folder' }).click()
await expect(page.getByRole('status').filter({ hasText: 'Saved' })).toHaveText(
Expand Down Expand Up @@ -180,3 +200,39 @@ test('saves into a folder, leaving a foreign manifest.json alone', async ({ page
'Saved 7 files to “site”, replacing 7.',
)
})

test('waits for Optimize, locks the settings while it runs, and can stop', async ({ page }) => {
await page.goto('/')
const jpeg = await makeJpeg(page, 3000, 2000)
await page
.locator('input[type=file]')
.setInputFiles(
['a.jpg', 'b.jpg', 'c.jpg'].map((name) => ({ name, mimeType: 'image/jpeg', buffer: jpeg })),
)

// Listed and read, but nothing is made until Optimize.
await expect(page.getByRole('cell', { name: 'Ready' })).toHaveCount(3, { timeout: 30_000 })
await expect(page.getByText('3 images', { exact: true })).toBeVisible()
await expect(page.getByRole('progressbar')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Download ZIP' })).toHaveCount(0)

await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 3 images' }).click()
await expect(page.getByRole('progressbar')).toBeVisible()
await expect(page.getByRole('radio', { name: 'CMS master' })).toBeDisabled()
await expect(page.getByLabel(/^Quality/)).toBeDisabled()
await expect(page.getByLabel('Role for b.jpg')).toBeDisabled()

// Stop: nothing is queued or running, and the settings unlock.
await page.getByRole('button', { name: 'Stop' }).click()
const optimize = page.getByRole('button', { name: /^Optimize \d images?$/ })
await expect(optimize).toBeFocused()
await expect(page.getByRole('progressbar')).toHaveCount(0)
await expect(page.getByRole('cell', { name: 'Queued' })).toHaveCount(0)
await expect(page.getByRole('radio', { name: 'CMS master' })).toBeEnabled()

// Optimize again finishes the rest; the same button then downloads.
await optimize.click()
await expect(page.getByText(/^3 done/)).toBeVisible({ timeout: 120_000 })
await expect(page.getByRole('button', { name: 'Download ZIP' })).toBeFocused()
})
6 changes: 3 additions & 3 deletions e2e/decode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ test('falls back to the browser for formats libvips can’t read', async ({ page
mimeType: 'image/bmp',
buffer: bmp(1000, 600),
})
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await expect(page.getByText('1000 × 600')).toBeVisible()
await page.getByRole('button', { name: 'scan.bmp', exact: true }).click()
// Opaque, so it's a photo with a JPEG fallback, not a transparent image with WebP.
for (const name of ['scan-1000w.avif', 'scan-1000w.jpg'])
await expect(page.getByText(name, { exact: true })).toBeVisible()
// Opaque, so a JPEG master, not a PNG.
await expect(page.getByText('scan.jpg', { exact: true })).toBeVisible()
})

test('says when the browser can’t open a HEIC either, in full on a phone', async ({ page }) => {
Expand Down
2 changes: 2 additions & 0 deletions e2e/focus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ test('every control shows a focus ring when tabbed to', async ({ page }) => {
mimeType: 'image/jpeg',
buffer: Buffer.from(jpeg),
})
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await page.getByRole('button', { name: 'photo.jpg', exact: true }).click()
await page.locator('h1').click()
Expand Down
3 changes: 3 additions & 0 deletions e2e/offline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ test('works offline after the first visit', async ({ page }) => {
mimeType: 'image/jpeg',
buffer: jpeg,
})
// A responsive set, so the AVIF encoder is used too.
await page.getByRole('radio', { name: 'Responsive set' }).click()
await page.getByRole('button', { name: 'Optimize 1 image' }).click()
await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 })
await page.getByRole('button', { name: 'photo.jpg', exact: true }).click()
await expect(page.getByText('photo-1000w.avif', { exact: true })).toBeVisible()
Expand Down
100 changes: 71 additions & 29 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

let input: HTMLInputElement
let dropzone: HTMLButtonElement | undefined = $state()
let nextButton: HTMLButtonElement | undefined = $state()
let table: HTMLTableElement | undefined = $state()
let dragging = $state(false)
let zipping = $state(false)
Expand All @@ -29,7 +30,51 @@
const totals = $derived(app.totals)
const done = $derived(app.done.length)
const failed = $derived(app.items.filter((i) => i.status === 'error').length)
const pending = $derived(app.items.length - done - failed)
const toOptimize = $derived(app.toOptimize)
const optimizing = $derived(app.optimizing)
/** Everything is made with the current settings: the files can be downloaded. */
const finished = $derived(done > 0 && !toOptimize && !optimizing)
const progress = $derived(describeProgress(app.items.length - failed))

// One button for the next step, Optimize and then Download ZIP, so focus stays put between them.
const next = $derived(
optimizing
? { label: 'Optimizing…', disabled: true }
: toOptimize
? {
label: `Optimize ${toOptimize} ${toOptimize === 1 ? 'image' : 'images'}`,
disabled: false,
}
: done
? { label: zipping ? 'Preparing…' : 'Download ZIP', disabled: zipping }
: { label: 'Optimize', disabled: true },
)

function describeProgress(count: number): string {
const parts = []
if (count)
parts.push(
optimizing || (done && done < count)
? `${done} of ${count} done`
: done
? `${done} done`
: `${count} ${count === 1 ? 'image' : 'images'}`,
)
if (failed) parts.push(`${failed} couldn’t be read`)
return parts.join(' · ')
}

function step() {
if (next.disabled) return
if (toOptimize) app.optimize()
else void downloadZip()
}

async function stop() {
app.stop()
await tick()
nextButton?.focus()
}

function describe(s: PlanSettings): string {
if (s.mode === 'master')
Expand Down Expand Up @@ -116,7 +161,7 @@
}

const button =
'rounded-md px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-40'
'rounded-md px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-40 aria-disabled:cursor-default aria-disabled:opacity-40'
const secondary = `${button} border border-neutral-200 hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900`
const primary = `${button} bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200`
</script>
Expand Down Expand Up @@ -176,59 +221,59 @@
label="Output"
value={app.settings.mode}
options={MODES}
disabled={optimizing}
onchange={(mode) => app.set({ mode })}
/>
{#if app.settings.mode === 'responsive'}
<Select
label="Role"
value={app.settings.role}
options={ROLES}
disabled={optimizing}
onchange={(role) => app.set({ role })}
/>
<Select
label="Quality"
value={app.settings.quality}
options={QUALITIES}
disabled={optimizing}
onchange={(quality) => app.set({ quality })}
/>
{:else}
<Select
label="Max width"
value={app.settings.masterWidth}
options={MASTER_WIDTHS}
disabled={optimizing}
onchange={(masterWidth) => app.set({ masterWidth })}
/>
{/if}
<!-- The actions wrap as one group, after the settings. -->
<div class="flex flex-wrap gap-2 sm:ml-auto">
<button type="button" class={secondary} onclick={() => input.click()}>Add images</button>
{#if app.settings.mode === 'responsive'}
<button
type="button"
class={secondary}
disabled={!done || pending > 0}
onclick={copyAll}
>
{copied ? 'Copied' : `Copy ${app.settings.markup === 'jsx' ? 'JSX' : 'HTML'}`}
</button>
{/if}
{#if canSaveToFolder}
<button
type="button"
class={secondary}
disabled={!done || pending > 0 || saving}
onclick={saveFolder}
>
{saving ? 'Saving…' : 'Save to folder'}
</button>
{#if optimizing}
<button type="button" class={secondary} onclick={stop}>Stop</button>
{:else if finished}
{#if app.settings.mode === 'responsive'}
<button type="button" class={secondary} onclick={copyAll}>
{copied ? 'Copied' : `Copy ${app.settings.markup === 'jsx' ? 'JSX' : 'HTML'}`}
</button>
{/if}
{#if canSaveToFolder}
<button type="button" class={secondary} disabled={saving} onclick={saveFolder}>
{saving ? 'Saving…' : 'Save to folder'}
</button>
{/if}
{/if}
<!-- aria-disabled, not disabled: a disabled button would drop focus. -->
<button
bind:this={nextButton}
type="button"
class={primary}
disabled={!done || pending > 0 || zipping}
onclick={downloadZip}
aria-disabled={next.disabled}
onclick={step}
>
{zipping ? 'Preparing…' : 'Download ZIP'}
{next.label}
</button>
</div>
</div>
Expand Down Expand Up @@ -269,6 +314,7 @@
path={app.settings.path}
markup={app.settings.markup}
placeholder={app.settings.placeholder}
locked={optimizing}
onsnippet={(patch) => app.setSnippet(patch)}
onrole={(role) => app.setRole(item, role)}
oncompare={(output) => app.compare(item, output)}
Expand All @@ -282,11 +328,7 @@
class="mt-4 flex items-center justify-between gap-4 text-sm text-neutral-500 tabular-nums dark:text-neutral-400"
aria-live="polite"
>
<span>
{pending ? `${done} of ${done + pending} done` : `${done} done`}{failed
? ` · ${failed} couldn’t be read`
: ''}
</span>
<span>{progress}</span>
<span class="flex items-center gap-4">
{#if done && totals.original > totals.output}
<span
Expand Down
Loading
Loading