${
@@ -557,7 +559,7 @@ const VERB = /^(Add|Compress|Convert|Cut|Delete|Edit|Extract|Merge|Protect|Redac
/** Names that keep their capital in a sentence ("How to remove Word metadata"). */
const PROPER = new Set(['Word', 'Excel', 'PowerPoint', 'Markdown', 'Mermaid', 'WhatsApp', 'Aadhaar']);
/** The photo tools, linked to each other first under "More tools". */
-const PHOTO_TOOLS = ['photo-editor', 'remove-background', 'whatsapp-sticker-maker', 'compress-image', 'resize-image', 'convert-image', 'remove-gif-background', 'remove-video-background'];
+const PHOTO_TOOLS = ['photo-editor', 'collage-maker', 'remove-background', 'whatsapp-sticker-maker', 'compress-image', 'resize-image', 'convert-image', 'remove-gif-background', 'remove-video-background'];
const howTo = (c: Copy, tool: Tool) => {
if (c.lang !== 'en') return c.t('tool.how', { tool: tool.name });
if (VERB.test(tool.name)) return `How to ${tool.name.replace(/\b[A-Z][a-z]+\b/g, (word) => (PROPER.has(word) ? word : word.toLowerCase()))}`;
@@ -906,6 +908,7 @@ ${HOME.what}
- **How do I make a photo's background white?** Same page: after the background is removed, pick White under Background and save as JPG or PNG.
- **Can I remove the background from a GIF or a video?** Yes: ${SITE.url}/remove-gif-background/ keeps every frame and its timing; ${SITE.url}/remove-video-background/ keeps the sound.
- **Is there a free photo editor that doesn't upload my photo?** ${SITE.url}/photo-editor/ crops to square, 4:5, 16:9 or story size, turns and flips, sets brightness, contrast, saturation and warmth, adds one-tap looks, text and emoji, and saves at full size, all on the device, with no sign-up.
+- **How do I make a photo collage without uploading the photos?** ${SITE.url}/collage-maker/ puts 1 to 9 photos in a grid (square, 4:5, story, 16:9 or A4 for printing), lets each photo be moved and zoomed in its frame, sets the gap, round corners, background and text, and saves JPG, PNG or WebP with no watermark.
- **How do I make a WhatsApp sticker from a photo?** ${SITE.url}/whatsapp-sticker-maker/ removes the background, adds a white outline, text and emoji, and saves a 512×512 WebP under 100 KB; WhatsApp's own "Create sticker" then adds it to the sticker tray.
## Why Fizzdoc instead of other online file tools
diff --git a/src/site.ts b/src/site.ts
index 966750c..c8a4203 100644
--- a/src/site.ts
+++ b/src/site.ts
@@ -15,6 +15,7 @@ export type ToolOp =
| 'redact-pdf'
| 'remove-bg'
| 'photo-edit'
+ | 'collage'
| 'scan-pdf'
| 'ocr-pdf'
| 'pdf-to-word'
@@ -1320,6 +1321,41 @@ TOOLS.push(
preset: { format: 'sticker' },
input: { accept: 'image/*' },
},
+ {
+ op: 'collage',
+ format: 'image',
+ slug: 'collage-maker',
+ name: 'Collage Maker',
+ summary: 'Put 2 to 9 photos in one picture, with a gap, round corners and text.',
+ action: 'Download collage',
+ title: 'Free Photo Collage Maker — No Upload, No Watermark | Fizzdoc',
+ description:
+ 'Make a photo collage free in your browser: pick a layout for 2 to 9 photos, set the gap, corners and size, add text. No upload, no sign-up, no watermark.',
+ h1: 'Make a photo collage without uploading your photos',
+ lede: 'Choose 2 to 9 photos, pick a layout and a shape (square, story, A4 and more), move and zoom each photo in its frame, and add a gap, round corners, a background colour and text. It all happens on your device.',
+ steps: [
+ 'Choose 2 to 9 photos. The order of the list is the order in the collage.',
+ 'Pick a layout and a shape, move and zoom each photo, and set the gap, corners, background and text.',
+ 'Pick the size and format, then click “Download collage”.',
+ ],
+ faq: [
+ PRIVACY_FAQ[0],
+ [
+ 'How many photos can a collage have?',
+ 'From 1 to 9. Each number of photos has its own layouts, such as side by side, stacked, one big photo with smaller ones, or a grid. If you add more than 9, the first 9 are used.',
+ ],
+ [
+ 'Can I move or zoom each photo?',
+ 'Yes. Open Photo, tap a photo to pick it, then drag it to choose what shows in its frame, and use Zoom to get closer. To swap photos, change their order in the file list.',
+ ],
+ [
+ 'What sizes can I save?',
+ 'Square, Portrait 4:5 and Story 9:16 for social media, Wide 16:9, and A4 for printing: 1080 or 2160 px on the short side, or 2480 px for A4 at 300 dpi. As JPG, PNG or WebP, with no watermark.',
+ ],
+ PRIVACY_FAQ[1],
+ ],
+ input: { accept: 'image/*', multiple: true },
+ },
{
op: 'photo-edit',
format: 'image',
@@ -1833,6 +1869,13 @@ const KEYWORDS: Record
= {
'remove video background without green screen',
'transparent background video',
],
+ 'collage-maker': [
+ 'photo collage maker',
+ 'collage maker online free',
+ 'combine photos into one',
+ 'picture grid maker',
+ 'photo collage without watermark',
+ ],
'photo-editor': [
'free online photo editor',
'crop photo to square',
diff --git a/src/tools/collage.ts b/src/tools/collage.ts
new file mode 100644
index 0000000..af328ea
--- /dev/null
+++ b/src/tools/collage.ts
@@ -0,0 +1,376 @@
+// Collage Maker: 1 to 9 photos in a grid, each one moved and zoomed inside its own frame, with a gap, round
+// corners, a background colour and words or emoji on top. The preview works from small copies of the photos;
+// saving decodes each one at full size, one at a time, so phones don't run out of memory.
+import './remove-bg.css';
+import './photo-edit.css';
+import { LAYOUTS, cells, fit, frame, place, type Cell } from '../engine/photo';
+import type { Output } from '../engine/local';
+import { stickerText } from './sticker-text';
+import { bringIntoView, button, decode, element, encode, row, saveButton, slider, tabs, throttle, type Icon } from './editor-ui';
+import { t, type UiKey } from '../i18n';
+
+export interface Collage {
+ save(options: { format?: string }): Promise;
+ update(files: File[]): void;
+ destroy(): void;
+}
+
+/** Longest side of the on-screen preview, and of the small copy kept of each photo for it. */
+const PREVIEW = 1200;
+/** At most this many photos; the rest of the list is left out. */
+const MAX = 9;
+/** Collage shapes as width ÷ height; A4 is for printing. */
+const SHAPES = { square: 1, portrait: 4 / 5, story: 9 / 16, wide: 16 / 9, a4: 210 / 297 } as const;
+type Shape = keyof typeof SHAPES;
+/** Saved size by the short side, in pixels: social posts, sharp, and A4 at 300 dpi. */
+const SIZES = [1080, 2160, 2480];
+const TABS = ['layout', 'photo', 'space', 'text'] as const;
+type Tab = (typeof TABS)[number];
+const LABELS: Record = { layout: ['layout', 'co.layout'], photo: ['photo', 'co.photo'], space: ['space', 'co.space'], text: ['text', 'st.text'] };
+const BACKGROUNDS = { white: '#ffffff', black: '#111111' } as const;
+
+/** Where a photo sits inside its frame; it follows the photo when the list is reordered. */
+interface Crop {
+ zoom: number;
+ cx: number;
+ cy: number;
+}
+const CENTRE: Crop = { zoom: 1, cx: 0.5, cy: 0.5 };
+
+export async function openCollage(files: File[], viewer: HTMLElement): Promise {
+ const state = { layout: 0, shape: 'square' as Shape, gap: 1.5, corners: 0, background: '#ffffff' as string };
+ const crops = new Map();
+ const thumbs = new Map();
+ let photos: File[] = [];
+ let selected = 0;
+ let destroyed = false;
+ const lang = document.documentElement.lang || undefined;
+ const percent = new Intl.NumberFormat(lang, { maximumFractionDigits: 1 });
+ const decimal = new Intl.NumberFormat(lang, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
+
+ /** A small copy of a photo for the preview (nothing bigger is kept in memory). */
+ const thumb = async (file: File) => {
+ const full = await decode(file);
+ const [w, h] = fit(full.width, full.height, PREVIEW);
+ const small = await createImageBitmap(full, { resizeWidth: w, resizeHeight: h, resizeQuality: 'high' });
+ full.close();
+ return small;
+ };
+
+ const stage = element('div', 'bg-stage');
+ const canvas = element('canvas', 'bg-canvas');
+ canvas.setAttribute('role', 'img');
+ canvas.setAttribute('aria-label', t('co.preview'));
+ stage.append(canvas);
+
+ const layoutCells = (): Cell[] => {
+ const [counts, columns] = LAYOUTS[photos.length][state.layout];
+ return cells(counts, columns);
+ };
+ /** Collage size from its longest side, or from its short side (the saved sizes). */
+ const sized = (longest: number): [number, number] => {
+ const ratio = SHAPES[state.shape];
+ return ratio >= 1 ? [longest, Math.round(longest / ratio)] : [Math.round(longest * ratio), longest];
+ };
+ const bySide = (short: number): [number, number] => {
+ const ratio = SHAPES[state.shape];
+ return ratio >= 1 ? [Math.round(short * ratio), short] : [short, Math.round(short / ratio)];
+ };
+ /** Photo `i`'s cell in pixels on a W×H collage. */
+ const box = (i: number, W: number, H: number) => place(layoutCells()[i], W, H, (state.gap / 100) * Math.min(W, H));
+ /** Draws a photo (any size of it: the frame works in fractions) into cell `i`. */
+ const drawCell = (context: CanvasRenderingContext2D, image: ImageBitmap, i: number, W: number, H: number) => {
+ const [dx, dy, dw, dh] = box(i, W, H);
+ const crop = crops.get(photos[i]) ?? CENTRE;
+ const [sx, sy, sw, sh] = frame(image.width, image.height, dw / dh, crop.zoom, crop.cx, crop.cy);
+ context.save();
+ context.beginPath();
+ context.roundRect(dx, dy, dw, dh, (state.corners / 100) * Math.min(W, H));
+ context.clip();
+ context.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
+ context.restore();
+ };
+ let text: ReturnType | undefined;
+ /** The preview, from the small copies. */
+ const draw = throttle(() => {
+ [canvas.width, canvas.height] = sized(PREVIEW);
+ const context = canvas.getContext('2d')!;
+ context.fillStyle = state.background;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ photos.forEach((file, i) => {
+ const image = thumbs.get(file);
+ if (image) drawCell(context, image, i, canvas.width, canvas.height);
+ });
+ // The picked photo is outlined while the Photo tab is open.
+ if (ui.current() === 'photo' && photos.length) {
+ const [dx, dy, dw, dh] = box(selected, canvas.width, canvas.height);
+ context.lineWidth = Math.max(3, canvas.width * 0.004);
+ context.strokeStyle = '#ffb547';
+ context.strokeRect(dx, dy, dw, dh);
+ }
+ text?.draw(context, canvas.width, canvas.height, ui.current() === 'text');
+ });
+
+ // ---- Top bar: a note (too many photos, or one that can't be read), and save ----
+ const top = element('div', 'pe-top');
+ const note = element('p', 'bg-hint co-note');
+ note.setAttribute('role', 'status');
+ top.append(note, saveButton(t('ws.download')));
+
+ const ui = tabs(viewer, TABS, (key) => [LABELS[key][0], t(LABELS[key][1])], () => draw());
+ const panels = ui.panels;
+
+ // ---- Layout: the grid, the shape and the size ----
+ const layoutRow = row(t('co.layout'), 'pe-looks co-layouts');
+ let builtLayouts = '';
+ const shapeRow = row(t('pe.shape'), 'pe-chips');
+ const shapeButtons = (Object.keys(SHAPES) as Shape[]).map((shape) => {
+ const node = button(t(shape === 'a4' ? 'co.a4' : (`pe.${shape}` as UiKey)));
+ node.onclick = () => {
+ state.shape = shape;
+ sync();
+ };
+ return node;
+ });
+ shapeRow.append(...shapeButtons);
+ const sizeSelect = element('select');
+ const sizeLabel = element('label', 'pe-size', t('bg.size'));
+ sizeLabel.append(sizeSelect);
+ panels.layout.append(layoutRow, shapeRow, sizeLabel);
+
+ // ---- Photo: pick one (on the picture, or with the numbered buttons), then move and zoom it in its frame ----
+ const pickRow = row(t('co.photo'), 'pe-chips');
+ const zoom = slider(t('pe.zoom'), 1, 3, 0.05, (v) => `${decimal.format(v)}×`, (value) => {
+ current().zoom = value;
+ draw();
+ });
+ const resetPhoto = button(t('pe.reset'));
+ resetPhoto.onclick = () => {
+ crops.delete(photos[selected]);
+ sync();
+ };
+ panels.photo.append(element('p', 'bg-hint', t('co.tapHint')), pickRow, zoom.wrap, resetPhoto, element('p', 'bg-hint', t('co.order')));
+ const current = () => {
+ const file = photos[selected];
+ if (!crops.has(file)) crops.set(file, { ...CENTRE });
+ return crops.get(file)!;
+ };
+
+ // ---- Space: gap, corners and background ----
+ const gap = slider(t('co.gap'), 0, 5, 0.25, (v) => `${percent.format(v)}%`, (value) => {
+ state.gap = value;
+ draw();
+ });
+ const corners = slider(t('co.corners'), 0, 10, 0.5, (v) => `${percent.format(v)}%`, (value) => {
+ state.corners = value;
+ draw();
+ });
+ const backgroundRow = row(t('bg.background'), 'pe-chips');
+ const backgroundButtons = (Object.keys(BACKGROUNDS) as (keyof typeof BACKGROUNDS)[]).map((key) => {
+ const node = button(t(`bg.${key}` as UiKey));
+ node.onclick = () => {
+ state.background = BACKGROUNDS[key];
+ sync();
+ };
+ return node;
+ });
+ // Any colour: a real button that opens the browser's own colour picker (the picker itself stays hidden).
+ const picker = element('input');
+ picker.type = 'color';
+ picker.value = '#ff8a00';
+ picker.className = 'bg-hidden';
+ picker.setAttribute('aria-label', t('bg.custom'));
+ picker.oninput = () => {
+ state.background = picker.value;
+ sync();
+ };
+ const custom = button(t('bg.custom'));
+ custom.onclick = () => {
+ state.background = picker.value;
+ sync();
+ picker.click();
+ };
+ backgroundRow.append(...backgroundButtons, custom, picker);
+ panels.space.append(gap.wrap, corners.wrap, backgroundRow);
+
+ /** Reflects the state in the controls and redraws. */
+ function sync() {
+ // The layout buttons are rebuilt only when the photo count or the shape changes, so focus stays put.
+ const key = `${photos.length}|${state.shape}`;
+ if (key !== builtLayouts) {
+ builtLayouts = key;
+ layoutRow.replaceChildren(
+ ...(LAYOUTS[photos.length] ?? []).map(([counts, columns], index) => {
+ const node = button('', 'pe-look');
+ const mini = element('canvas');
+ [mini.width, mini.height] = sized(96);
+ const context = mini.getContext('2d')!;
+ context.fillStyle = '#3f3f46';
+ for (const cell of cells(counts, columns)) {
+ const [x, y, w, h] = place(cell, mini.width, mini.height, 4);
+ context.fillRect(x, y, w, h);
+ }
+ node.append(mini);
+ node.setAttribute('aria-label', `${t('co.layout')} ${index + 1}`);
+ node.onclick = () => {
+ state.layout = index;
+ sync();
+ };
+ return node;
+ }),
+ );
+ }
+ [...layoutRow.children].forEach((node, i) => node.setAttribute('aria-pressed', String(i === state.layout)));
+ [...pickRow.children].forEach((node, i) => node.setAttribute('aria-pressed', String(i === selected)));
+ shapeButtons.forEach((node, i) => node.setAttribute('aria-pressed', String(Object.keys(SHAPES)[i] === state.shape)));
+ const preset = (Object.values(BACKGROUNDS) as string[]).includes(state.background);
+ backgroundButtons.forEach((node, i) => node.setAttribute('aria-pressed', String(Object.values(BACKGROUNDS)[i] === state.background)));
+ custom.setAttribute('aria-pressed', String(!preset));
+ gap.set(state.gap);
+ corners.set(state.corners);
+ if (photos.length) zoom.set(current().zoom);
+ const picked = sizeSelect.value || String(SIZES[0]);
+ sizeSelect.replaceChildren(...SIZES.map((short) => new Option(t('bg.widthOption', { w: bySide(short)[0], h: bySide(short)[1] }), String(short))));
+ sizeSelect.value = picked;
+ canvas.tabIndex = ui.current() === 'photo' ? 0 : -1; // arrow keys move the picked photo
+ draw();
+ }
+
+ // ---- On the picture: tap a photo to pick it, drag to move it in its frame; arrow keys do the same ----
+ /** Moves the picked photo by a fraction of its frame, stopping at the photo's edges. */
+ const nudge = (crop: Crop, from: { cx: number; cy: number }, dx: number, dy: number) => {
+ const image = thumbs.get(photos[selected]);
+ if (!image) return;
+ const [, , dw, dh] = box(selected, canvas.width, canvas.height);
+ const [, , sw, sh] = frame(image.width, image.height, dw / dh, crop.zoom, crop.cx, crop.cy);
+ const clamp = (v: number, half: number) => Math.min(1 - half, Math.max(half, v));
+ // Dragging the photo right shows more of its left side.
+ crop.cx = clamp(from.cx - dx * (sw / image.width), sw / image.width / 2);
+ crop.cy = clamp(from.cy - dy * (sh / image.height), sh / image.height / 2);
+ draw();
+ };
+ let drag: { id: number; x: number; y: number; cx: number; cy: number } | undefined;
+ canvas.addEventListener('pointerdown', (event) => {
+ if (drag || event.defaultPrevented || event.button !== 0 || ui.current() !== 'photo') return;
+ const rect = canvas.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
+ const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
+ const index = photos.findIndex((_, i) => {
+ const [dx, dy, dw, dh] = box(i, canvas.width, canvas.height);
+ return x >= dx && x <= dx + dw && y >= dy && y <= dy + dh;
+ });
+ if (index < 0) return;
+ event.preventDefault();
+ canvas.setPointerCapture(event.pointerId);
+ selected = index;
+ const crop = current();
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, cx: crop.cx, cy: crop.cy };
+ sync();
+ });
+ canvas.addEventListener('pointermove', (event) => {
+ if (event.pointerId !== drag?.id) return;
+ const rect = canvas.getBoundingClientRect();
+ const [, , dw, dh] = box(selected, canvas.width, canvas.height);
+ const scale = canvas.width / rect.width;
+ nudge(current(), drag, ((event.clientX - drag.x) * scale) / dw, ((event.clientY - drag.y) * scale) / dh);
+ });
+ for (const type of ['pointerup', 'pointercancel'] as const)
+ canvas.addEventListener(type, (event) => {
+ if (event.pointerId === drag?.id) drag = undefined;
+ });
+ canvas.addEventListener('keydown', (event) => {
+ const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; // moves the photo, like a drag
+ if (!step || ui.current() !== 'photo' || !photos.length) return;
+ event.preventDefault();
+ const by = event.shiftKey ? 0.2 : 0.05;
+ const crop = current();
+ nudge(crop, crop, step[0] * by, step[1] * by);
+ });
+
+ /**
+ * Follows the file list: new photos get a small copy, removed ones are let go, the order is the list's.
+ * A photo the browser can't read is left out and named in the note.
+ */
+ async function update(list: File[]) {
+ const usable: File[] = [];
+ const unreadable: string[] = [];
+ for (const file of list.slice(0, MAX)) {
+ if (!thumbs.has(file))
+ try {
+ thumbs.set(file, await thumb(file));
+ } catch {
+ unreadable.push(file.name);
+ continue;
+ }
+ if (destroyed) return;
+ usable.push(file);
+ }
+ for (const [file, image] of thumbs)
+ if (!usable.includes(file)) {
+ image.close();
+ thumbs.delete(file);
+ crops.delete(file);
+ }
+ if (!usable.length) {
+ const { LocalError } = await import('../engine/local');
+ throw new LocalError('BAD_IMAGE');
+ }
+ if (usable.length !== photos.length) state.layout = 0;
+ photos = usable;
+ selected = Math.min(selected, photos.length - 1);
+ note.textContent = [list.length > MAX ? t('co.max') : '', unreadable.length ? `${t('error.BAD_IMAGE')} (${unreadable.join(', ')})` : ''].filter(Boolean).join(' ');
+ pickRow.replaceChildren(
+ ...photos.map((file, i) => {
+ const node = button(String(i + 1));
+ node.setAttribute('aria-label', `${i + 1}. ${file.name}`);
+ node.onclick = () => {
+ selected = i;
+ sync();
+ };
+ return node;
+ }),
+ );
+ sync();
+ }
+ // Updates run one after another, so a quick add-then-remove can't finish in the wrong order.
+ let pending: Promise = Promise.resolve();
+ const queue = (list: File[]) => (pending = pending.catch(() => {}).then(() => update(list)));
+
+ text = stickerText(canvas, draw, () => ui.current() !== 'text');
+ panels.text.append(text.panel);
+ await queue(files);
+ viewer.classList.add('bg-editor', 'pe-editor', 'co-editor');
+ viewer.replaceChildren(top, stage, ui.tabList, ...TABS.map((key) => panels[key]));
+ ui.show('layout');
+ bringIntoView(viewer);
+
+ return {
+ update: (list) => void queue(list),
+ async save(options) {
+ await pending; // the collage shows every photo in the list before it is saved
+ await document.fonts.ready;
+ const format = options.format === 'png' ? 'png' : options.format === 'webp' ? 'webp' : 'jpg';
+ const [W, H] = bySide(Number(sizeSelect.value));
+ const out = new OffscreenCanvas(W, H);
+ const context = out.getContext('2d') as unknown as CanvasRenderingContext2D;
+ context.fillStyle = state.background;
+ context.fillRect(0, 0, W, H);
+ // One photo at a time at full size, let go as soon as it is drawn.
+ for (const [i, file] of photos.entries()) {
+ const image = await decode(file);
+ drawCell(context, image, i, W, H);
+ image.close();
+ }
+ text?.draw(context, W, H);
+ const { blob, ext } = await encode(out, format);
+ return { blob, name: `collage.${ext}`, summary: `${W} × ${H} px` };
+ },
+ destroy() {
+ destroyed = true;
+ for (const image of thumbs.values()) image.close();
+ viewer.classList.remove('bg-editor', 'pe-editor', 'co-editor');
+ delete viewer.dataset.tab;
+ viewer.replaceChildren();
+ },
+ };
+}
diff --git a/src/tools/editor-ui.ts b/src/tools/editor-ui.ts
new file mode 100644
index 0000000..8ad126a
--- /dev/null
+++ b/src/tools/editor-ui.ts
@@ -0,0 +1,158 @@
+// Building blocks shared by the Photo Editor and the Collage Maker: buttons, our own line icons, rulers and
+// the tab bar. The look lives in photo-edit.css (.pe-*).
+
+export const element = (tag: K, className = '', text = '') => {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text) node.textContent = text;
+ return node;
+};
+
+export const button = (label: string, className = 'bg-btn') => {
+ const node = element('button', className, label);
+ node.type = 'button';
+ return node;
+};
+
+// Our own line icons, drawn like the site's tool icons.
+const ICONS = {
+ looks: ' ',
+ crop: ' ',
+ light: ' ',
+ text: ' ',
+ compare: ' ',
+ save: ' ',
+ layout: ' ',
+ photo: ' ',
+ space: ' ',
+};
+export type Icon = keyof typeof ICONS;
+
+/** A button with one of the icons above and a visible label. */
+export const iconButton = (icon: Icon, label: string, className: string) => {
+ const node = button('', className);
+ node.innerHTML = `${ICONS[icon]} `;
+ node.append(element('span', '', label));
+ return node;
+};
+
+/** A labelled group of buttons. */
+export const row = (label: string, className: string) => {
+ const node = element('div', className);
+ node.setAttribute('role', 'group');
+ node.setAttribute('aria-label', label);
+ return node;
+};
+
+/** A ruler: the browser's own range input (so keys and screen readers work), with its name and a big value. */
+export function slider(label: string, min: number, max: number, step: number, show: (value: number) => string, onInput: (value: number) => void) {
+ const range = element('input');
+ range.type = 'range';
+ range.min = String(min);
+ range.max = String(max);
+ range.step = String(step);
+ range.setAttribute('aria-label', label);
+ const value = element('span', 'pe-value');
+ value.setAttribute('aria-hidden', 'true'); // the range announces itself through aria-valuetext
+ const head = element('span', 'pe-slider-head');
+ head.append(element('span', 'pe-name', label), value);
+ const wrap = element('label', min < 0 ? 'pe-slider' : 'pe-slider from-start');
+ wrap.append(head, range);
+ const set = (v: number) => {
+ range.value = String(v);
+ value.textContent = show(v);
+ range.setAttribute('aria-valuetext', value.textContent);
+ // -1…1 for the fill from the middle, or 0…1 from the start when the ruler starts at its lowest value.
+ range.style.setProperty('--v', String(min < 0 ? v / max : (v - min) / (max - min)));
+ };
+ range.oninput = () => {
+ set(Number(range.value));
+ onInput(Number(range.value));
+ };
+ return { range, wrap, set };
+}
+
+/**
+ * The tab bar and one panel per tab. `show(key)` switches; arrow keys, Home and End move between tabs.
+ * `viewer.dataset.tab` names the open one, for CSS.
+ */
+export function tabs(viewer: HTMLElement, keys: readonly K[], label: (key: K) => [Icon, string], onShow: () => void) {
+ let current = keys[0];
+ const tabList = element('div', 'pe-tabs');
+ tabList.setAttribute('role', 'tablist');
+ const panels = {} as Record;
+ const buttons = keys.map((key) => {
+ const node = iconButton(...label(key), 'pe-tab');
+ node.id = `pe-tab-${key}`;
+ node.setAttribute('role', 'tab');
+ node.setAttribute('aria-controls', `pe-${key}`);
+ node.onclick = () => show(key);
+ const panel = element('div', 'pe-panel');
+ panel.id = `pe-${key}`;
+ panel.setAttribute('role', 'tabpanel');
+ panel.setAttribute('aria-labelledby', node.id);
+ panels[key] = panel;
+ return node;
+ });
+ tabList.append(...buttons);
+ tabList.addEventListener('keydown', (event) => {
+ const at = keys.indexOf(current);
+ const next = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: keys.length - 1 }[event.key];
+ if (next === undefined) return;
+ event.preventDefault();
+ show(keys[(next + keys.length) % keys.length]);
+ buttons[keys.indexOf(current)].focus();
+ });
+ function show(key: K) {
+ current = key;
+ viewer.dataset.tab = key;
+ keys.forEach((k, i) => {
+ buttons[i].setAttribute('aria-selected', String(k === key));
+ buttons[i].tabIndex = k === key ? 0 : -1;
+ panels[k].hidden = k !== key;
+ });
+ onShow();
+ }
+ return { tabList, panels, show, current: () => current };
+}
+
+/** Opens a photo (turned the right way up from its EXIF); a file the browser can't read becomes BAD_IMAGE. */
+export async function decode(file: File) {
+ try {
+ return await createImageBitmap(file);
+ } catch {
+ const { LocalError } = await import('../engine/local');
+ throw new LocalError('BAD_IMAGE');
+ }
+}
+
+/** Calls `paint` at most once a frame, however fast sliders and drags ask. */
+export function throttle(paint: () => void) {
+ let queued = false;
+ return () => {
+ if (queued) return;
+ queued = true;
+ requestAnimationFrame(() => {
+ queued = false;
+ paint();
+ });
+ };
+}
+
+/** Brings the editor into view once the page around it has settled (the file list and options appear too). */
+export const bringIntoView = (viewer: HTMLElement) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => viewer.scrollIntoView({ block: 'start', behavior: 'instant' })));
+
+/** The top bar's Download button: it runs the page's own save, with its progress and result. */
+export function saveButton(label: string) {
+ const node = iconButton('save', label, 'pe-save');
+ node.onclick = () => document.getElementById('run')?.click();
+ return node;
+}
+
+/** Encodes a finished canvas; the extension follows the type the browser really wrote (Safari may not write WebP). */
+export async function encode(canvas: OffscreenCanvas, format: string) {
+ const blob = await canvas.convertToBlob({ type: format === 'jpg' ? 'image/jpeg' : `image/${format}`, quality: 0.92 });
+ const ext = ({ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' } as Record)[blob.type] ?? format;
+ return { blob, ext };
+}
diff --git a/src/tools/photo-edit.css b/src/tools/photo-edit.css
index c0eeb7a..93babef 100644
--- a/src/tools/photo-edit.css
+++ b/src/tools/photo-edit.css
@@ -123,6 +123,11 @@
touch-action: pan-y; /* a sideways drag changes the value; up and down still scroll the page */
cursor: ew-resize;
}
+/* Collage: in the Photo tab a finger moves the picked photo in its frame. */
+.pe-editor.co-editor[data-tab='photo'] .bg-canvas {
+ touch-action: none;
+ cursor: grab;
+}
/* ---- Tabs ---- */
.pe-tabs {
@@ -424,3 +429,27 @@
clip-path: inset(50%);
}
}
+
+/* Collage Maker: a note in the top bar (too many photos, or one that can't be read) and the layout thumbnails. */
+.co-note {
+ margin: 0;
+ padding-inline-start: 8px;
+}
+.co-layouts .pe-look {
+ justify-content: center;
+ width: 64px;
+ min-height: 44px;
+}
+.co-layouts .pe-look:first-child {
+ margin-inline-end: 0;
+ padding-inline-end: 4px;
+ border-inline-end: 0;
+}
+.co-layouts .pe-look canvas {
+ aspect-ratio: auto;
+ background: #0a0a0b;
+}
+.co-layouts .pe-look[aria-pressed='true']::after {
+ right: 0;
+ bottom: 0;
+}
diff --git a/src/tools/photo-edit.ts b/src/tools/photo-edit.ts
index 146dcc4..460160b 100644
--- a/src/tools/photo-edit.ts
+++ b/src/tools/photo-edit.ts
@@ -7,6 +7,7 @@ import './photo-edit.css';
import { FILTERS, NONE, adjustPixels, combine, fit, frame, turned, type Adjust, type Filter } from '../engine/photo';
import type { Output } from '../engine/local';
import { stickerText } from './sticker-text';
+import { bringIntoView, button, decode, element, encode, iconButton, row, saveButton, slider, tabs, throttle } from './editor-ui';
import { t, type UiKey } from '../i18n';
export interface PhotoEditor {
@@ -25,48 +26,11 @@ const SIZES = [100, 75, 50, 25];
const TABS = ['looks', 'crop', 'light', 'text'] as const;
type Tab = (typeof TABS)[number];
-// Our own line icons, drawn like the site's tool icons.
-const svg = (paths: string) =>
- `${paths} `;
-const ICONS = {
- looks: ' ',
- crop: ' ',
- light: ' ',
- text: ' ',
- compare: ' ',
- save: ' ',
-};
const LABELS: Record = { looks: 'pe.looks', crop: 'pe.crop', light: 'pe.light', text: 'st.text' };
-const element = (tag: K, className = '', text = '') => {
- const node = document.createElement(tag);
- if (className) node.className = className;
- if (text) node.textContent = text;
- return node;
-};
-const button = (label: string, className = 'bg-btn') => {
- const node = element('button', className, label);
- node.type = 'button';
- return node;
-};
-/** A button with one of the icons above and a visible label. */
-const iconButton = (icon: keyof typeof ICONS, label: string, className: string) => {
- const node = button('', className);
- node.innerHTML = svg(ICONS[icon]);
- node.append(element('span', '', label));
- return node;
-};
-
export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise {
- let photo: ImageBitmap;
- try {
- photo = await createImageBitmap(file); // also turns phone photos the right way up (EXIF)
- } catch {
- const { LocalError } = await import('../engine/local');
- throw new LocalError('BAD_IMAGE');
- }
+ const photo = await decode(file);
const state = { shape: 'original' as Shape, zoom: 1, cx: 0.5, cy: 0.5, turns: 0, flipX: false, flipY: false, look: 'original' as Filter, own: { ...NONE } as Adjust };
- let tab: Tab = 'looks';
let chosen: Slider = 'brightness'; // the slider shown on phones, and the one a sideways swipe changes
/** The kept part of the turned photo: [x, y, width, height] in photo pixels. */
const kept = () => {
@@ -117,97 +81,19 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
context.globalCompositeOperation = 'source-over';
}
// The text's selection box only shows while the Text tab is open.
- if (!plain) text?.draw(context, target.width, target.height, target === canvas && tab === 'text');
- };
- // Sliders fire fast: draw at most once a frame.
- let queued = false;
- const draw = () => {
- if (queued) return;
- queued = true;
- requestAnimationFrame(() => {
- queued = false;
- paint(canvas, PREVIEW, original);
- });
- };
-
- const row = (label: string, className: string) => {
- const node = element('div', className);
- node.setAttribute('role', 'group');
- node.setAttribute('aria-label', label);
- return node;
- };
- /** A ruler: the browser's own range input (so keys and screen readers work), with its name and a big signed value. */
- const slider = (label: string, min: number, max: number, step: number, show: (value: number) => string, onInput: (value: number) => void) => {
- const range = element('input');
- range.type = 'range';
- range.min = String(min);
- range.max = String(max);
- range.step = String(step);
- range.setAttribute('aria-label', label);
- const value = element('span', 'pe-value');
- value.setAttribute('aria-hidden', 'true'); // the range announces itself through aria-valuetext
- const head = element('span', 'pe-slider-head');
- head.append(element('span', 'pe-name', label), value);
- const wrap = element('label', 'pe-slider');
- wrap.append(head, range);
- const set = (v: number) => {
- range.value = String(v);
- value.textContent = show(v);
- range.setAttribute('aria-valuetext', value.textContent);
- // -1…1 for the fill from the middle (or 0…1 from the start for Zoom).
- range.style.setProperty('--v', String(min < 0 ? v / max : (v - min) / (max - min)));
- };
- range.oninput = () => {
- set(Number(range.value));
- onInput(Number(range.value));
- };
- return { range, wrap, set };
+ if (!plain) text?.draw(context, target.width, target.height, target === canvas && tab() === 'text');
};
+ const draw = throttle(() => paint(canvas, PREVIEW, original));
// ---- Top bar: hold to compare, and save ----
const top = element('div', 'pe-top');
const compare = iconButton('compare', t('bg.compare'), 'bg-btn bg-compare pe-compare');
- const save = iconButton('save', t('ws.download'), 'pe-save');
- save.onclick = () => document.getElementById('run')?.click(); // the page's own save, with its progress and result
- top.append(compare, save);
+ top.append(compare, saveButton(t('ws.download')));
// ---- Tabs ----
- const tabList = element('div', 'pe-tabs');
- tabList.setAttribute('role', 'tablist');
- const panels = {} as Record;
- const tabButtons = TABS.map((key) => {
- const node = iconButton(key, t(LABELS[key]), 'pe-tab');
- node.id = `pe-tab-${key}`;
- node.setAttribute('role', 'tab');
- node.setAttribute('aria-controls', `pe-${key}`);
- node.onclick = () => show(key);
- const panel = element('div', 'pe-panel');
- panel.id = `pe-${key}`;
- panel.setAttribute('role', 'tabpanel');
- panel.setAttribute('aria-labelledby', node.id);
- panels[key] = panel;
- return node;
- });
- tabList.append(...tabButtons);
- // Arrow keys move between tabs.
- tabList.addEventListener('keydown', (event) => {
- const at = TABS.indexOf(tab);
- const next = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: TABS.length - 1 }[event.key];
- if (next === undefined) return;
- event.preventDefault();
- show(TABS[(next + TABS.length) % TABS.length]);
- tabButtons[TABS.indexOf(tab)].focus();
- });
- function show(key: Tab) {
- tab = key;
- viewer.dataset.tab = key;
- TABS.forEach((k, i) => {
- tabButtons[i].setAttribute('aria-selected', String(k === key));
- tabButtons[i].tabIndex = k === key ? 0 : -1;
- panels[k].hidden = k !== key;
- });
- draw();
- }
+ const ui = tabs(viewer, TABS, (key) => [key, t(LABELS[key])], () => draw());
+ const panels = ui.panels;
+ const tab = () => ui.current();
// ---- Looks: each one shows the photo with that look ----
const lookRow = row(t('pe.looks'), 'pe-looks');
@@ -245,7 +131,6 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
state.zoom = value;
sync();
});
- zoom.wrap.classList.add('from-start');
const dragHint = element('p', 'bg-hint', t('pe.dragHint'));
const turnRow = row(t('pe.turn'), 'pe-chips');
const turnButton = (label: UiKey, act: () => void) => {
@@ -338,7 +223,7 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
const clamp = (value: number, half: number) => Math.min(1 - half, Math.max(half, value));
const startDrag = (event: PointerEvent) => {
if (drag || event.defaultPrevented || event.button !== 0) return; // one finger at a time; a text layer took it
- if (tab === 'crop' ? !canvas.classList.contains('pe-movable') : tab !== 'light') return;
+ if (tab() === 'crop' ? !canvas.classList.contains('pe-movable') : tab() !== 'light') return;
event.preventDefault();
canvas.setPointerCapture(event.pointerId);
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, cx: state.cx, cy: state.cy, value: state.own[chosen] };
@@ -346,7 +231,7 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
canvas.addEventListener('pointermove', (event) => {
if (event.pointerId !== drag?.id) return;
const rect = canvas.getBoundingClientRect();
- if (tab === 'light') {
+ if (tab() === 'light') {
// The photo's full width is the whole range, -100 to +100.
state.own[chosen] = Math.round(Math.min(100, Math.max(-100, drag.value + ((event.clientX - drag.x) / rect.width) * 200)));
sliders[SLIDERS.indexOf(chosen)].set(state.own[chosen]);
@@ -388,14 +273,13 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
compare.addEventListener('keyup', () => showOriginal(false));
// The text layers come from the Sticker Maker; registered first, so a touch on a layer moves the layer.
- text = stickerText(canvas, draw, () => tab !== 'text');
+ text = stickerText(canvas, draw, () => tab() !== 'text');
panels.text.append(text.panel);
canvas.addEventListener('pointerdown', startDrag);
- viewer.replaceChildren(top, stage, tabList, ...TABS.map((key) => panels[key]));
- show('looks');
+ viewer.replaceChildren(top, stage, ui.tabList, ...TABS.map((key) => panels[key]));
+ ui.show('looks');
sync();
- // Bring the editor into view once the page around it has settled (the file list and options appear too).
- requestAnimationFrame(() => requestAnimationFrame(() => viewer.scrollIntoView({ block: 'start', behavior: 'instant' })));
+ bringIntoView(viewer);
return {
async save(options) {
@@ -403,16 +287,14 @@ export async function openPhotoEditor(file: File, viewer: HTMLElement): Promise<
const [, , w, h] = kept().box;
const format = options.format === 'png' ? 'png' : options.format === 'webp' ? 'webp' : 'jpg';
const longest = Math.round((Math.max(w, h) * Number(sizeSelect.value)) / 100);
- const type = format === 'jpg' ? 'image/jpeg' : `image/${format}`;
// JPG has no see-through pixels: a clear part of the photo becomes white instead of black.
const render = async (maxArea?: number) => {
const out = new OffscreenCanvas(1, 1);
paint(out, longest, false, format === 'jpg' ? '#ffffff' : undefined, maxArea);
- return { out, blob: await out.convertToBlob({ type, quality: 0.92 }) };
+ return { out, ...(await encode(out, format)) };
};
// Full size first; a phone that can't make a canvas that big (about 16.7 million pixels on iPhone) gets the largest it can.
- const { out, blob } = await render(Infinity).catch(() => render());
- const ext = ({ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' } as Record)[blob.type] ?? format;
+ const { out, blob, ext } = await render(Infinity).catch(() => render());
return { blob, name: `${file.name.replace(/\.[^.]+$/, '')}-edited.${ext}`, summary: `${out.width} × ${out.height} px` };
},
destroy() {
diff --git a/tests/app.e2e.ts b/tests/app.e2e.ts
index 69fb160..9853ed1 100644
--- a/tests/app.e2e.ts
+++ b/tests/app.e2e.ts
@@ -1671,6 +1671,52 @@ test('photo editor: crops to a square, turns, applies a look and adds text, then
expect(seen.violations).toEqual([]);
});
+test('collage maker: four photos in a 2×2 grid, in list order, then reordered, saved at 1080 px', async ({ page }) => {
+ const seen = watch(page);
+ await page.goto('/collage-maker/');
+ const solid = async (color: string) => {
+ const bytes = await page.evaluate(async (fill) => {
+ const canvas = new OffscreenCanvas(400, 300);
+ const c = canvas.getContext('2d')!;
+ c.fillStyle = fill;
+ c.fillRect(0, 0, 400, 300);
+ return [...new Uint8Array(await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer())];
+ }, color);
+ return { name: `${color.slice(1)}.png`, mimeType: 'image/png', buffer: Buffer.from(bytes) };
+ };
+ await page.locator('#file-input').setInputFiles([await solid('#ff0000'), await solid('#00ff00'), await solid('#0000ff'), await solid('#ffff00')]);
+ await expect(page.getByRole('tab', { name: 'Layout' })).toHaveAttribute('aria-selected', 'true');
+ // Photo: a finger moves the picked photo (no page scroll), and each photo can be picked with a button too.
+ await page.getByRole('tab', { name: 'Photo' }).click();
+ await expect(page.locator('.bg-canvas')).toHaveCSS('touch-action', 'none');
+ await page.getByRole('group', { name: 'Photo' }).getByRole('button', { name: '2. 00ff00.png' }).click();
+ await expect(page.getByRole('button', { name: '2. 00ff00.png' })).toHaveAttribute('aria-pressed', 'true');
+ await page.getByRole('tab', { name: 'Space' }).click();
+ await page.getByRole('slider', { name: 'Gap' }).fill('0');
+ await page.getByRole('combobox', { name: 'Save as' }).selectOption('png');
+ const quadrants = async () => {
+ await page.getByRole('button', { name: 'Download collage' }).click();
+ await expect(page.locator('#status')).toContainText('1080 × 1080 px');
+ const saved = await downloadBytes(page);
+ expect(saved.name).toBe('collage.png');
+ return page.evaluate(async (data) => {
+ const bitmap = await createImageBitmap(new Blob([new Uint8Array(data)], { type: 'image/png' }));
+ const c = new OffscreenCanvas(bitmap.width, bitmap.height).getContext('2d')!;
+ c.drawImage(bitmap, 0, 0);
+ const at = (x: number, y: number) => [...c.getImageData(x, y, 1, 1).data.slice(0, 3)].map((v) => (v > 127 ? 1 : 0)).join('');
+ return { size: [bitmap.width, bitmap.height], cells: [at(270, 270), at(810, 270), at(270, 810), at(810, 810)] };
+ }, [...saved.bytes]);
+ };
+ const first = await quadrants();
+ expect(first.size).toEqual([1080, 1080]);
+ // Red, green, blue, yellow: in the order of the file list.
+ expect(first.cells).toEqual(['100', '010', '001', '110']);
+ // Moving the first photo down swaps the first two cells.
+ await page.locator('#file-list li').first().getByRole('button', { name: 'Move down' }).click();
+ await expect.poll(async () => (await quadrants()).cells).toEqual(['010', '100', '001', '110']);
+ expect(seen.violations).toEqual([]);
+});
+
test('phones use the small background model, so the big one never loads and the tab stays alive', async ({ browser, baseURL }) => {
test.setTimeout(240_000);
for (const phone of [true, false]) {
diff --git a/tests/photo.test.ts b/tests/photo.test.ts
index 0f90c25..2ebc61e 100644
--- a/tests/photo.test.ts
+++ b/tests/photo.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { FILTERS, NONE, adjustPixels, combine, fit, frame, turned } from '../src/engine/photo';
+import { FILTERS, LAYOUTS, NONE, adjustPixels, cells, combine, fit, frame, place, turned } from '../src/engine/photo';
const pixel = (r: number, g: number, b: number) => new Uint8ClampedArray([r, g, b, 255]);
@@ -52,3 +52,26 @@ describe('photo edits', () => {
}
});
});
+
+describe('collage layouts', () => {
+ it('has one cell per photo, filling the collage with no overlaps', () => {
+ for (const [count, layouts] of Object.entries(LAYOUTS))
+ for (const [counts, columns] of layouts) {
+ const all = cells(counts, columns);
+ expect(all.length, `${counts} ${columns ?? ''}`).toBe(Number(count));
+ expect(all.reduce((sum, [, , w, h]) => sum + w * h, 0)).toBeCloseTo(1);
+ for (const [i, a] of all.entries())
+ for (const b of all.slice(i + 1)) {
+ const apart = a[0] + a[2] <= b[0] + 1e-9 || b[0] + b[2] <= a[0] + 1e-9 || a[1] + a[3] <= b[1] + 1e-9 || b[1] + b[3] <= a[1] + 1e-9;
+ expect(apart).toBe(true);
+ }
+ }
+ });
+
+ it('leaves the same gap around the edge and between cells', () => {
+ const [left, right] = cells([2]).map((cell) => place(cell, 1000, 500, 20));
+ expect(left).toEqual([20, 20, 470, 460]);
+ expect(right[0] - (left[0] + left[2])).toBe(20);
+ expect(1000 - (right[0] + right[2])).toBe(20);
+ });
+});