Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7716a56ff3432bc0c1e4837c
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Workspace.tsx
// The PDF workspace in the landing hero: drop files, see every page, change them, download.
// Files are read and written on this device; nothing is sent anywhere.
import { useEffect, useLayoutEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import type * as Engine from "./lib/engine";
import type { NumberPos, PageSize, Shrink } from "./lib/engine";
import { useFlip } from "./lib/flip";
import { parseRanges } from "./lib/ranges";
import { useDragSort } from "./lib/sort";
import { currentTool, type Tab } from "./lib/tools";
import { Dialog, Segmented, save, size, toast, useMedia } from "./ui";
let engineLoad: Promise<typeof Engine> | null = null;
function engine() {
engineLoad ??= import("./lib/engine").catch((err) => {
engineLoad = null;
throw err;
});
return engineLoad;
}
type FileInfo = { id: string; name: string; kind: "pdf" | "image"; letter: string; bytes: number };
/** One page in the result: page `index` of file `src`, turned `rotate` degrees on top of its own rotation. */
type Page = { key: string; src: string; index: number; rotate: number };
type Part = { name: string; label: string; data: Blob };
type Job = "pdf" | "extract" | "split" | "images" | "zip";
type Work = { by: Job; label: string; done: number; total: number };
const TOUCH = matchMedia("(pointer: coarse)").matches;
const NARROW = "(max-width: 720px)";
const tool = currentTool();
const LETTER_PAPER = ["US", "CA", "MX", "PH", "CL", "CO", "VE", "GT", "CR", "DO", "PR", "PA", "SV", "NI", "HN"];
function defaultPaper(): PageSize {
try {
const region = new Intl.Locale(navigator.language).maximize().region;
return region && LETTER_PAPER.includes(region) ? "letter" : "a4";
} catch {
return "a4";
}
}
const toLetter = (n: number): string => (n < 26 ? "" : toLetter(Math.floor(n / 26) - 1)) + String.fromCharCode(65 + (n % 26));
const stemOf = (name: string) => name.replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-").trim() || "document";
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const typing = (el: EventTarget | null) => el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName));
function move<T>(list: T[], from: number, to: number): T[] {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to > from ? to - 1 : to, 0, item);
return next;
}
const SHRINK_HINT: Record<Shrink, string> = {
off: "Photos and scans stay as they are.",
medium: "Photos and scans get smaller. Text and drawings stay sharp.",
strong: "Photos and scans get as small as they can and look softer up close. Text stays sharp.",
};
export function Workspace() {
const [files, setFiles] = useState<FileInfo[]>([]);
const [doc, setDoc] = useState<{ pages: Page[]; history: Page[][] }>({ pages: [], history: [] });
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [opening, setOpening] = useState<string | null>(null);
const [problems, setProblems] = useState<string[]>([]); // files that could not be added
const [failure, setFailure] = useState<string | null>(null); // a save that did not work
const [work, setWork] = useState<Work | null>(null);
const [over, setOver] = useState(false);
const [tab, setTab] = useState<Tab>(tool?.tab || "pdf");
const [numbers, setNumbers] = useState<NumberPos | "">(tool?.numbers || "");
const [numberStyle, setNumberStyle] = useState<"plain" | "of">("plain");
const [imageSize, setImageSize] = useState<PageSize>(defaultPaper);
const [shrink, setShrink] = useState<Shrink>(tool?.shrink || "off");
const [splitMode, setSplitMode] = useState<"each" | "ranges">(() => (new URLSearchParams(location.search).get("ranges") ? "ranges" : "each"));
const [ranges, setRanges] = useState(() => new URLSearchParams(location.search).get("ranges") || "");
const [imageType, setImageType] = useState<"jpg" | "png">("jpg");
const [dpi, setDpi] = useState<"150" | "300">("150");
const [splitParts, setSplitParts] = useState<Part[] | null>(null);
const [imageParts, setImageParts] = useState<Part[] | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const [sheet, setSheet] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [announce, setAnnounce] = useState("");
const narrow = useMedia(NARROW);
const input = useRef<HTMLInputElement>(null);
const pageList = useRef<HTMLOListElement>(null);
const fileList = useRef<HTMLOListElement>(null);
const problemBox = useRef<HTMLDivElement>(null);
const ids = useRef(0);
const letters = useRef(0);
const focusAfter = useRef<string | null>(null);
const unsaved = useRef(false); // changes made since the last download
const pageFlip = useFlip(pageList);
const fileFlip = useFlip(fileList);
const { pages, history } = doc;
const started = files.length > 0;
const byId = new Map(files.map((f) => [f.id, f]));
const order = [...new Set(pages.map((p) => p.src))];
const shown = order.map((id) => byId.get(id)!);
const counts = new Map<string, number>();
for (const p of pages) counts.set(p.src, (counts.get(p.src) || 0) + 1);
const chosen = pages.filter((p) => selected.has(p.key));
const multi = shown.length > 1;
const hasImages = shown.some((f) => f.kind === "image");
const bytes = shown.reduce((sum, f) => sum + f.bytes, 0);
const stem = shown.length === 1 ? stemOf(shown[0].name) : shown.length && shown.every((f) => f.kind === "image") ? "images" : "merged";
const mainName = shown.length === 1 && shown[0].kind === "pdf" ? `${stem}-${shrink === "off" ? "edited" : "compressed"}.pdf` : `${stem}.pdf`;
const busy = !!work || !!opening;
useEffect(() => {
document.body.classList.toggle("app-mode", started);
if (started) window.scrollTo(0, 0);
}, [started]);
// Results describe the pages and options they were made from; any change makes them stale.
useEffect(() => setSaved(null), [pages, numbers, numberStyle, imageSize, shrink]);
useEffect(() => setSplitParts(null), [pages, numbers, numberStyle, imageSize, shrink, splitMode, ranges]);
useEffect(() => setImageParts(null), [pages, imageType, dpi]);
useLayoutEffect(() => {
pageFlip.play();
fileFlip.play();
// Keyboard moves and deletes keep focus on the control the person was using.
const want = focusAfter.current;
if (!want) return;
focusAfter.current = null;
const [key, action] = want.split("|");
const el = document.querySelector<HTMLButtonElement>(`[data-focus="${key}|${action}"]`);
if (el && !el.disabled) el.focus();
else document.querySelector<HTMLButtonElement>(`[data-focus="${key}|select"]`)?.focus();
}, [pages]);
/** Changes the pages and, unless told not to, remembers the old ones for Undo. */
const commit = (change: (pages: Page[]) => Page[], remember = true) => {
pageFlip.before();
fileFlip.before();
if (remember) unsaved.current = true;
setDoc((d) => ({ pages: change(d.pages), history: remember ? [...d.history.slice(-49), d.pages] : d.history }));
};
const undo = () => {
pageFlip.before();
fileFlip.before();
setDoc((d) => (d.history.length ? { pages: d.history[d.history.length - 1], history: d.history.slice(0, -1) } : d));
setAnnounce("Last change undone.");
};
const addFiles = async (list: File[]) => {
if (!list.length) return;
setProblems([]);
setOpening(list[0].name);
let eng: typeof Engine;
try {
eng = await engine();
} catch {
setOpening(null);
setProblems(["The PDF tools could not load. Check your connection, then reload this page."]);
return;
}
// The first files are the starting point, not a change to undo.
const first = files.length === 0;
let added = 0;
for (const [n, file] of list.entries()) {
setOpening(list.length > 1 ? `${n + 1} of ${list.length}: ${file.name}` : file.name);
const id = `f${ids.current++}`;
try {
const got = await eng.open(id, file);
const letter = toLetter(letters.current++);
setFiles((f) => [...f, { id, name: file.name, kind: got.kind, letter, bytes: file.size }]);
commit((ps) => [...ps, ...Array.from({ length: got.pages }, (_, i) => ({ key: `${id}:${i}`, src: id, index: i, rotate: 0 }))], !first);
added++;
} catch (err) {
setProblems((p) => [...p, err instanceof eng.FileProblem ? err.message : `${file.name} could not be opened.`]);
}
}
setOpening(null);
// A file dropped while scrolled down: bring the reason it was left out into view.
window.requestAnimationFrame(() => problemBox.current?.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }));
if (!added) return;
if (!first) toast(added === 1 && list.length === 1 ? `Added ${list[0].name} at the end.` : `Added ${plural(added, "file")} at the end.`);
// Someone who came to split or make images on a phone gets those choices right away.
if (first && tool && tool.tab !== "pdf" && matchMedia(NARROW).matches) setSheet(true);
};
const addRef = useRef(addFiles);
addRef.current = addFiles;
// Closing the tab or swiping back would lose the page order and changes, so the browser asks first.
useEffect(() => {
const warn = (e: BeforeUnloadEvent) => {
if (!unsaved.current) return;
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, []);
// Files dropped anywhere on the page are added (and the browser does not navigate away to them).
useEffect(() => {
let depth = 0;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes("Files");
const enter = (e: DragEvent) => { if (hasFiles(e)) { e.preventDefault(); depth++; setOver(true); engine().catch(() => {}); } };
const dragover = (e: DragEvent) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer!.dropEffect = "copy"; } };
const leave = (e: DragEvent) => { if (hasFiles(e) && --depth <= 0) { depth = 0; setOver(false); } };
const drop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0;
setOver(false);
void addRef.current(Array.from(e.dataTransfer!.files));
};
window.addEventListener("dragenter", enter);
window.addEventListener("dragover", dragover);
window.addEventListener("dragleave", leave);
window.addEventListener("drop", drop);
return () => {
window.removeEventListener("dragenter", enter);
window.removeEventListener("dragover", dragover);
window.removeEventListener("dragleave", leave);
window.removeEventListener("drop", drop);
};
}, []);
const pick = () => input.current?.click();
const onPick = (e: ChangeEvent<HTMLInputElement>) => {
const list = Array.from(e.target.files || []);
e.target.value = "";
void addFiles(list);
};
const startOver = () => {
void engine().then((eng) => eng.reset(), () => {});
setFiles([]);
setDoc({ pages: [], history: [] });
setSelected(new Set());
setProblems([]);
setSheet(false);
setConfirmReset(false); // the dialog unmounts with the editor, before its close animation can report back
// The next file starts from the job's own settings; on phones these sit in the closed Options sheet.
setTab(tool?.tab || "pdf");
setNumbers(tool?.numbers || "");
setNumberStyle("plain");
setShrink(tool?.shrink || "off");
unsaved.current = false;
letters.current = 0;
};
const toggle = (key: string) =>
setSelected((s) => {
const next = new Set(s);
if (!next.delete(key)) next.add(key);
return next;
});
const selectAll = () => setSelected(new Set(pages.map((p) => p.key)));
const clearSelection = () => setSelected(new Set());
const rotate = (keys: string[]) => {
const set = new Set(keys);
commit((ps) => ps.map((p) => (set.has(p.key) ? { ...p, rotate: p.rotate + 90 } : p)));
setAnnounce(keys.length === 1 ? "Page turned to the right." : `${keys.length} pages turned to the right.`);
};
const remove = (keys: string[]) => {
const set = new Set(keys);
const left = pages.filter((p) => !set.has(p.key));
if (keys.length === 1) {
const i = pages.findIndex((p) => p.key === keys[0]);
const next = left[Math.min(i, left.length - 1)];
if (next) focusAfter.current = `${next.key}|delete`;
}
commit(() => left);
setSelected((s) => new Set([...s].filter((k) => !set.has(k))));
const text = keys.length === 1 ? `Page ${pages.findIndex((p) => p.key === keys[0]) + 1} deleted.` : `${plural(keys.length, "page")} deleted.`;
setAnnounce(`${text} ${plural(left.length, "page")} left.`);
toast(text, { label: "Undo", run: undo });
};
const movePage = (key: string, to: number, focus?: string) => {
const from = pages.findIndex((p) => p.key === key);
if (from < 0 || to === from || to === from + 1) return;
if (focus) focusAfter.current = `${key}|${focus}`;
commit((ps) => move(ps, from, to));
setAnnounce(`Page moved to position ${to > from ? to : to + 1} of ${pages.length}.`);
};
const moveFile = (id: string, to: number, focus?: string) => {
const from = order.indexOf(id);
if (from < 0 || to === from || to === from + 1) return;
if (focus) focusAfter.current = `${id}|${focus}`;
const next = move(order, from, to);
commit((ps) => next.flatMap((f) => ps.filter((p) => p.src === f)));
setAnnounce(`${byId.get(id)?.name} moved to position ${to > from ? to : to + 1} of ${order.length}.`);
};
const removeFile = (id: string) => {
commit((ps) => ps.filter((p) => p.src !== id));
const text = `${byId.get(id)?.name} removed.`;
setAnnounce(text);
toast(text, { label: "Undo", run: undo });
};
const pageSort = useDragSort((key, to) => movePage(key, to), "x");
const fileSort = useDragSort((id, to) => moveFile(id, to), "y");
// Desktop shortcuts: undo, delete the selected pages, select all, clear the selection.
const keys = useRef<(e: KeyboardEvent) => void>(() => {});
keys.current = (e) => {
if (!started || e.defaultPrevented || typing(e.target) || document.querySelector(".dialog-backdrop")) return;
const mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && e.key.toLowerCase() === "z" && history.length) { e.preventDefault(); undo(); }
else if (mod && e.key.toLowerCase() === "a" && pages.length) { e.preventDefault(); selectAll(); }
else if ((e.key === "Delete" || e.key === "Backspace") && chosen.length) { e.preventDefault(); remove(chosen.map((p) => p.key)); }
else if (e.key === "Escape" && chosen.length) clearSelection();
};
useEffect(() => {
const key = (e: KeyboardEvent) => keys.current(e);
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, []);
const output = { numbers: numbers || null, numberStyle, imageSize, shrink };
const specs = (list: Page[]) => list.map(({ src, index, rotate }) => ({ src, index, rotate }));
const run = async (by: Job, label: string, job: (eng: typeof Engine, progress: Engine.Progress) => Promise<void>) => {
setFailure(null);
setWork({ by, label, done: 0, total: 0 });
try {
const eng = await engine();
await job(eng, (label, done, total) => setWork((w) => (w ? { ...w, label, done, total } : w)));
} catch (err) {
const eng = await engine().catch(() => null);
const text = eng && err instanceof eng.FileProblem ? err.message : "The file could not be made. Your pages are unchanged, so try again.";
setFailure(text);
if (matchMedia(NARROW).matches && !sheet) toast(text); // the panel that shows it is closed
} finally {
setWork(null);
}
};
/** What compressing did to a file, in a sentence. */
const shrinkNote = (name: string, { bytes: data, shrunk }: Engine.Built) =>
shrunk > (data.length + shrunk) * 0.1
? `${name} is ${size(data.length)}, down from ${size(data.length + shrunk)}.`
: `${name} is ${size(data.length)}. It has few photos or scans, so compress could not make it much smaller.`;
const downloadAll = () =>
run("pdf", "Making your PDF", async (eng, progress) => {
const made = await eng.build(specs(pages), output, progress);
const note = shrink === "off" ? undefined : shrinkNote(mainName, made);
save(made.bytes, mainName, note);
setSaved(note || null);
unsaved.current = false;
});
const extract = () =>
run("extract", "Extracting pages", async (eng, progress) => {
const name = chosen.length === 1 ? `${stem}-page-${pages.indexOf(chosen[0]) + 1}.pdf` : `${stem}-${chosen.length}-pages.pdf`;
save((await eng.build(specs(chosen), output, progress)).bytes, name);
});
const rangeResult = splitMode === "ranges" ? parseRanges(ranges, pages.length) : null;
const rangeError = typeof rangeResult === "string" && ranges.trim() ? rangeResult : null;
const groups: [number, number][] | null =
splitMode === "each" ? pages.map((_, i) => [i + 1, i + 1] as [number, number]) : Array.isArray(rangeResult) ? rangeResult : null;
const split = () => {
if (!groups?.length) return;
run("split", "Splitting", async (eng) => {
const made: Part[] = [];
for (const [n, [a, b]] of groups.entries()) {
setWork((w) => w && { ...w, label: "Making PDFs", done: n, total: groups.length });
const { bytes: data } = await eng.build(specs(pages.slice(a - 1, b)), output);
const label = a === b ? `Page ${a}` : `Pages ${a} to ${b}`;
let name = a === b ? `${stem}-page-${a}.pdf` : `${stem}-pages-${a}-${b}.pdf`;
if (made.some((m) => m.name === name)) name = name.replace(/\.pdf$/, `-${n + 1}.pdf`); // the same range typed twice
made.push({ name, label, data: new Blob([data], { type: "application/pdf" }) });
}
setSplitParts(made);
setAnnounce(`${plural(made.length, "PDF")} ready to download.`);
});
};
const makeImages = () =>
run("images", "Making images", async (eng) => {
const made: Part[] = [];
const type = imageType === "png" ? "image/png" : "image/jpeg";
for (const [n, p] of pages.entries()) {
setWork((w) => w && { ...w, label: "Making images", done: n, total: pages.length });
const data = await eng.pageImage(p, type, Number(dpi));
made.push({ name: `${stem}-page-${n + 1}.${imageType}`, label: `Page ${n + 1}`, data });
}
setImageParts(made);
setAnnounce(`${plural(made.length, "image")} ready to download.`);
});
const zip = (parts: Part[], name: string) =>
run("zip", "Making the zip", async () => {
const { downloadZip } = await import("client-zip");
const blob = await downloadZip(parts.map((p) => ({ name: p.name, input: p.data, lastModified: new Date() }))).blob();
save(new Blob([blob], { type: "application/zip" }), name);
unsaved.current = false;
});
const problemList = problems.length > 0 && (
<div className="problems" role="alert" ref={problemBox}>
{problems.map((p, i) => (
<p className="notice notice-error enter" key={p + i}>
<span>{p}</span>
<button className="icon-btn" type="button" aria-label="Dismiss" onClick={() => setProblems((list) => list.filter((_, j) => j !== i))}>
<Icon name="x" />
</button>
</p>
))}
</div>
);
const fileInput = (
<input ref={input} id="file-input" type="file" multiple accept="application/pdf,.pdf,image/*,.heic,.heif" hidden aria-label="Choose PDF or image files" onChange={onPick} />
);
if (!started) {
return (
<div className={`drop card${over ? " is-over" : ""}`} key="drop" onPointerEnter={() => engine().catch(() => {})}>
{fileInput}
<img className="art drop-art" src="/art/card-pdf.webp" alt="" width="220" height="196" />
<p className="drop-title">{tool?.drop || "Drop PDFs or images here"}</p>
<button className="btn btn-lg" type="button" aria-busy={!!opening} disabled={!!opening} onClick={pick}><span>Choose files</span></button>
<p className="hint" aria-live="polite">{opening ? `Opening ${opening}` : "PDF, JPG or PNG. Your files stay on this device."}</p>
{problemList}
</div>
);
}
const results = (parts: Part[], zipName: string, what: string) => {
const shareable = typeof navigator.canShare === "function" &&
navigator.canShare({ files: parts.map((p) => new File([p.data], p.name, { type: p.data.type })) });
return (
<div className="parts enter">
<div className="parts-actions">
{parts.length > 1 && <button className="btn btn-block" type="button" aria-busy={work?.by === "zip"} disabled={busy} onClick={() => zip(parts, zipName)}><span>Download all as .zip</span></button>}
{shareable && (
<button className="btn btn-line btn-block" type="button" onClick={() =>
navigator.share({ files: parts.map((p) => new File([p.data], p.name, { type: p.data.type })) }).catch(() => {})}>
<Icon name="share" /> Share {plural(parts.length, what)}
</button>
)}
</div>
<ul className="part-list">
{parts.map((p) => (
<li key={p.name}>
<span className="grow">
<span className="part-name">{p.name}</span>
<span className="part-meta">{p.label}, {size(p.data.size)}</span>
</span>
<button className="btn btn-quiet btn-sm" type="button" aria-label={`Download ${p.name}`} onClick={() => save(p.data, p.name)}>Download</button>
</li>
))}
</ul>
</div>
);
};
const heading = tab === "split" ? "Split into several PDFs" : tab === "images" ? "Save pages as images" : multi ? "Merge into one PDF" : "Save your PDF";
const panel = (inSheet: boolean) => (
<div className="panel-body">
<Segmented className="tabs" label="Save as" value={tab} onChange={(t) => { setTab(t); setFailure(null); }} options={[["pdf", "PDF"], ["split", "Split"], ["images", "Images"]]} />
{!inSheet && <h2 className="side-title">{heading}</h2>}
{tab === "pdf" && (
<>
<div className="field">
<span>Compress</span>
<Segmented label="Compress" value={shrink} onChange={setShrink} options={[["off", "Off"], ["medium", "Medium"], ["strong", "Strong"]]} />
<small>{SHRINK_HINT[shrink]}</small>
</div>
<label className="field">
<span>Page numbers</span>
<select className="select" value={numbers} onChange={(e) => setNumbers(e.target.value as NumberPos | "")}>
<option value="">None</option>
<option value="bottom-center">Bottom, centered</option>
<option value="bottom-right">Bottom right</option>
<option value="bottom-left">Bottom left</option>
<option value="top-center">Top, centered</option>
<option value="top-right">Top right</option>
<option value="top-left">Top left</option>
</select>
</label>
{numbers && (
<label className="field">
<span>Number style</span>
<select className="select" value={numberStyle} onChange={(e) => setNumberStyle(e.target.value as "plain" | "of")}>
<option value="plain">1, 2, 3</option>
<option value="of">1 of {pages.length || 1}</option>
</select>
</label>
)}
{hasImages && (
<label className="field">
<span>Page size for images</span>
<select className="select" value={imageSize} onChange={(e) => setImageSize(e.target.value as PageSize)}>
<option value="a4">A4</option>
<option value="letter">US Letter</option>
<option value="fit">Same size as the image</option>
</select>
</label>
)}
<button className="btn btn-lg btn-block" type="button" aria-busy={work?.by === "pdf"} disabled={!pages.length || busy} onClick={downloadAll}><span>Download PDF</span></button>
{failure ? <p className="notice notice-error enter" role="alert">{failure}</p> : saved ? <p className="notice notice-ok enter" role="status">{saved}</p>
: <p className="hint center">{plural(pages.length, "page")}, {size(bytes)} of files, made on this device</p>}
</>
)}
{tab === "split" && (
<>
<Segmented label="How to split" value={splitMode} onChange={setSplitMode} options={[["each", "Every page"], ["ranges", "By ranges"]]} />
{splitMode === "ranges" ? (
<label className="field">
<span>Ranges</span>
<input className="input" value={ranges} onChange={(e) => setRanges(e.target.value)} placeholder={pages.length > 3 ? `1-3, 4-${pages.length}` : "1, 2"} inputMode="text" autoComplete="off" aria-invalid={!!rangeError} aria-describedby="ranges-note" />
<small id="ranges-note" className={rangeError ? "field-error" : undefined}>
{rangeError || "Each range becomes one PDF. Use the page numbers shown."}
</small>
</label>
) : (
<p className="hint">One PDF for each page.</p>
)}
<button className={`btn btn-lg btn-block${splitParts ? " btn-line" : ""}`} type="button" aria-busy={work?.by === "split"} disabled={!groups?.length || busy} onClick={split}>
<span>{groups?.length ? `Split into ${plural(groups.length, "PDF")}` : "Split"}</span>
</button>
{failure && <p className="notice notice-error enter" role="alert">{failure}</p>}
{splitParts && results(splitParts, `${stem}-split.zip`, "PDF")}
{!splitParts && <p className="hint">To take out a few pages as one PDF, select them and choose Extract.</p>}
</>
)}
{tab === "images" && (
<>
<div className="field">
<span>Format</span>
<Segmented label="Format" value={imageType} onChange={setImageType} options={[["jpg", "JPG"], ["png", "PNG"]]} />
<small>{imageType === "jpg" ? "JPG files are small and open anywhere." : "PNG keeps every pixel, so files are larger."}</small>
</div>
<div className="field">
<span>Resolution</span>
<Segmented label="Resolution" value={dpi} onChange={setDpi} options={[["150", "Standard"], ["300", "High"]]} />
<small>{dpi === "150" ? "150 dpi, for screens, email and chat." : "300 dpi, for printing and zooming in."}</small>
</div>
<button className={`btn btn-lg btn-block${imageParts ? " btn-line" : ""}`} type="button" aria-busy={work?.by === "images"} disabled={!pages.length || busy} onClick={makeImages}>
<span>Make {plural(pages.length, `${imageType.toUpperCase()} image`)}</span>
</button>
{failure && <p className="notice notice-error enter" role="alert">{failure}</p>}
{imageParts && results(imageParts, `${stem}-images.zip`, "image")}
</>
)}
</div>
);
const progress = opening ? (
<div className="work" role="status"><span>Opening {opening}</span><span className="bar"><span className="bar-wait" /></span></div>
) : work && (
<div className="work" role="status">
<span>{work.label}{work.total > 1 && work.label !== "Saving" ? ` (${Math.min(work.done + 1, work.total)} of ${work.total})` : ""}</span>
<span className="bar"><span style={{ width: `${work.total ? Math.round((work.done / work.total) * 100) : 8}%` }} /></span>
</div>
);
return (
<div className="editor enter" key="editor">
{fileInput}
<div className="editor-head">
<div className="editor-title-wrap">
<h1 className="editor-title">{shown.length === 1 ? shown[0].name : shown.length ? `${shown.length} files` : "No pages"}</h1>
<p className="editor-meta">
<Icon name="lock" /> {plural(pages.length, "page")}{multi ? " in one PDF" : ""}. Nothing is uploaded.
</p>
</div>
<div className="row editor-actions">
<button className="btn btn-line btn-sm" type="button" onClick={pick}><Icon name="plus" /> Add files</button>
<button className="btn btn-quiet btn-sm" type="button" onClick={() => (history.length ? setConfirmReset(true) : startOver())}>Start over</button>
</div>
</div>
{problemList}
{multi && (
<section className="files-wrap" aria-label="Files, in order">
<ol className="files" ref={fileList} onClickCapture={fileSort.onClickCapture}>
{shown.map((f, i) => {
const d = fileSort.drag;
const cls = ["file", d?.id === f.id && "is-dragging", d && d.id !== f.id && d.at === i && (d.after ? "drop-after" : "drop-before")];
return (
<li key={f.id} data-flip={f.id} className={cls.filter(Boolean).join(" ")} {...fileSort.item(f.id, i)}>
<span className="file-badge" aria-hidden="true">{f.letter}</span>
<span className="file-name">{f.name}</span>
<span className="file-meta">{plural(counts.get(f.id) || 0, "page")}</span>
<span className="file-tools" data-no-drag>
<button className="tool" type="button" data-focus={`${f.id}|up`} aria-label={`Move ${f.name} up`} disabled={i === 0} onClick={() => moveFile(f.id, i - 1, "up")}><Icon name="up" /></button>
<button className="tool" type="button" data-focus={`${f.id}|down`} aria-label={`Move ${f.name} down`} disabled={i === order.length - 1} onClick={() => moveFile(f.id, i + 2, "down")}><Icon name="down" /></button>
<button className="tool" type="button" aria-label={`Remove ${f.name}`} onClick={() => removeFile(f.id)}><Icon name="x" /></button>
</span>
</li>
);
})}
</ol>
</section>
)}
<div className="editor-grid">
<div className="editor-main">
<div className="pagebar">
{chosen.length ? (
<>
<div className="pagebar-sel">
<strong className="pagebar-count">{chosen.length} selected</strong>
<button className="btn btn-quiet btn-sm" type="button" onClick={clearSelection}>Clear</button>
</div>
<div className="pagebar-actions">
{narrow ? (
chosen.length < pages.length && <button className="btn btn-quiet btn-sm" type="button" onClick={selectAll}>Select all</button>
) : (
<>
<button className="btn btn-line btn-sm" type="button" onClick={() => rotate(chosen.map((p) => p.key))}><Icon name="rotate" /> Rotate</button>
<button className="btn btn-line btn-sm" type="button" onClick={() => remove(chosen.map((p) => p.key))}><Icon name="trash" /> Delete</button>
<button className="btn btn-sm" type="button" aria-busy={work?.by === "extract"} disabled={busy} onClick={extract}><span>Extract as PDF</span></button>
</>
)}
</div>
</>
) : (
<>
<span className="pagebar-hint">{TOUCH ? "Tap to select, hold to drag." : "Click to select, drag to reorder."}</span>
<div className="pagebar-actions">
{history.length > 0 && (narrow
? <button className="btn btn-quiet btn-sm btn-icon" type="button" aria-label="Undo" onClick={undo}><Icon name="undo" /></button>
: <button className="btn btn-quiet btn-sm" type="button" onClick={undo}><Icon name="undo" /> Undo</button>)}
{pages.length > 0 && <button className="btn btn-quiet btn-sm" type="button" onClick={selectAll}>Select all</button>}
</div>
</>
)}
</div>
{pages.length ? (
<ol className="pages" ref={pageList} onClickCapture={pageSort.onClickCapture} aria-label="Pages, in order">
{pages.map((p, i) => {
const file = byId.get(p.src)!;
const d = pageSort.drag;
const isSel = selected.has(p.key);
const cls = ["page", isSel && "is-selected", d?.id === p.key && "is-dragging", d && d.id !== p.key && d.at === i && (d.after ? "drop-after" : "drop-before")];
const origin = file.kind === "image" ? file.name : `${file.name}, page ${p.index + 1}`;
return (
<li key={p.key} data-flip={p.key} className={cls.filter(Boolean).join(" ")} {...pageSort.item(p.key, i)}>
<button className="page-select" type="button" data-focus={`${p.key}|select`} aria-pressed={isSel}
aria-label={`Page ${i + 1}${multi ? `, from ${origin}` : ""}${p.rotate % 360 ? `, turned ${p.rotate % 360} degrees` : ""}`}
onClick={() => toggle(p.key)}>
<Thumb src={p.src} index={p.index} rotate={p.rotate} />
<span className="page-no">{i + 1}</span>
{multi && <span className="page-from" title={origin}>{file.letter}{file.kind === "pdf" ? ` p${p.index + 1}` : ""}</span>}
<span className="page-tick" aria-hidden="true"><Icon name="check" /></span>
</button>
<div className="page-tools" data-no-drag>
<button className="tool" type="button" data-focus={`${p.key}|left`} aria-label={`Move page ${i + 1} earlier`} disabled={i === 0} onClick={() => movePage(p.key, i - 1, "left")}><Icon name="left" /></button>
<button className="tool" type="button" aria-label={`Turn page ${i + 1} to the right`} onClick={() => rotate([p.key])}><Icon name="rotate" /></button>
<button className="tool" type="button" data-focus={`${p.key}|delete`} aria-label={`Delete page ${i + 1}`} onClick={() => remove([p.key])}><Icon name="trash" /></button>
<button className="tool" type="button" data-focus={`${p.key}|right`} aria-label={`Move page ${i + 1} later`} disabled={i === pages.length - 1} onClick={() => movePage(p.key, i + 2, "right")}><Icon name="right" /></button>
</div>
</li>
);
})}
</ol>
) : (
<div className="empty-state enter">
<h2>No pages left</h2>
<p>Undo to bring them back, or add files.</p>
<div className="row">
{history.length > 0 && <button className="btn" type="button" onClick={undo}><Icon name="undo" /> Undo</button>}
<button className="btn btn-line" type="button" onClick={pick}><Icon name="plus" /> Add files</button>
</div>
</div>
)}
</div>
{!narrow && (
<aside className="editor-side" aria-label="Save">
<section className="card side-card">{panel(false)}</section>
</aside>
)}
</div>
{narrow && (
<div className="action-bar">
{chosen.length ? (
<>
<button key="rotate" className="btn btn-line enter" type="button" onClick={() => rotate(chosen.map((p) => p.key))}><Icon name="rotate" /> Rotate</button>
<button key="delete" className="btn btn-line enter" type="button" onClick={() => remove(chosen.map((p) => p.key))}><Icon name="trash" /> Delete</button>
<button key="extract" className="btn enter" type="button" aria-busy={work?.by === "extract"} disabled={busy} onClick={extract}><span>Extract</span></button>
</>
) : (
<>
<button key="options" className="btn btn-line btn-options enter" type="button" aria-haspopup="dialog" onClick={() => setSheet(true)}><Icon name="sliders" /> Options</button>
<button key="download" className="btn enter" type="button" aria-busy={work?.by === "pdf"} disabled={!pages.length || busy} onClick={downloadAll}><span>Download PDF</span></button>
</>
)}
</div>
)}
{narrow && sheet && (
<Dialog title={heading} onClose={() => setSheet(false)}>
{panel(true)}
</Dialog>
)}
{confirmReset && (
<Dialog title="Start over?" onClose={() => setConfirmReset(false)}
foot={(close) => (
<>
<button className="btn btn-line" type="button" onClick={close}>Keep working</button>
<button className="btn" type="button" onClick={() => { startOver(); close(); }}>Start over</button>
</>
)}>
<p>Your pages and changes leave this page. The files on your device stay as they are.</p>
</Dialog>
)}
{progress}
{over && <div className="drop-overlay" aria-hidden="true"><span>Drop to add files</span></div>}
<p className="sr-only" aria-live="polite">{announce}</p>
</div>
);
}
function Thumb({ src, index, rotate }: { src: string; index: number; rotate: number }) {
const [url, setUrl] = useState<string | null>(null);
const [shown, setShown] = useState(false);
const [failed, setFailed] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
let live = true;
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect();
engine()
.then((eng) => eng.thumbnail(src, index))
.then((u) => live && setUrl(u), () => live && setFailed(true));
}, { rootMargin: "600px 0px" });
io.observe(ref.current!);
return () => {
live = false;
io.disconnect();
};
}, [src, index]);
return (
<span className="thumb" ref={ref}>
{failed ? <span className="thumb-none">No preview</span> : !shown && <span className="thumb-wait skeleton" />}
{url && <img src={url} alt="" draggable={false} className={shown ? "is-shown" : undefined} onLoad={() => setShown(true)} style={{ transform: `rotate(${rotate}deg)` }} />}
</span>
);
}
const PATHS: Record<string, ReactNode> = {
x: <path d="M5 5l10 10M15 5 5 15" />,
plus: <path d="M10 4v12M4 10h12" />,
left: <path d="M12 4 6 10l6 6" />,
right: <path d="m8 4 6 6-6 6" />,
up: <path d="m4 12 6-6 6 6" />,
down: <path d="m4 8 6 6 6-6" />,
rotate: <><path d="M15.5 9.5A5.5 5.5 0 1 1 10 4.5h3" /><path d="m11 2 2.5 2.5L11 7" /></>,
trash: <><path d="M4 6h12M8 6V4h4v2M6 6l.7 10h6.6L14 6" /></>,
check: <path d="m5 10.5 3.2 3L15 7" />,
undo: <><path d="M5 8h7a4 4 0 0 1 0 8H8" /><path d="M8 5 5 8l3 3" /></>,
lock: <><rect x="4.5" y="9" width="11" height="8" rx="2" /><path d="M7 9V6.5a3 3 0 0 1 6 0V9" /></>,
sliders: <><path d="M4 6h7M15 6h1M4 14h1M9 14h7" /><circle cx="13" cy="6" r="2" /><circle cx="7" cy="14" r="2" /></>,
share: <><path d="M10 3v9M6.5 6.5 10 3l3.5 3.5" /><path d="M5 10v5.5h10V10" /></>,
};
function Icon({ name }: { name: keyof typeof PATHS }) {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{PATHS[name]}
</svg>
);
}
Change history
Quality pass: compress, PDF to JPG, phone layout, job links, llms.txt and index.md
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync