pdf-free.openapps.xyz

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.
Owned app · rev 0341fee61377
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.
← All files
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
0341fee613 · 4 hours ago
Bounded source sync
f00de5deb0 · 1 day ago
Bounded source sync
3c8124e576 · 1 day ago
Bounded source sync
5c14663e0f · 1 day ago
Bounded source sync
ee5c852f40 · 1 day ago