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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules/
# Left behind if a bake is interrupted mid-book.
baked/.*.tmp/

post.md
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Why it exists, and how libraries have tried to show a whole book with one thumbn
- **Viewer.** Scrubbing, play/pause, speed control, boomerang mode and keyboard controls. Share links open the book at the page you're on (`#m=…&p=42`). The viewer also links to the item page and the manifest.
- **Exports.** GIF, video (WebM or MP4, whichever the browser can record) or a contact-sheet JPEG. Choose frames, width, speed and pages (no blanks, plates only, or all). A credit line with the title and institution is added by default. GIFs are encoded in the browser with [gifenc](https://github.com/mattdesl/gifenc), vendored in `vendor/`.
- **Flash mode.** Every card cycles its pages at once, like the old IA search results.
- Right-to-left books scrub in the right direction. Light and dark themes follow the OS setting.
- Books scrub the way their pages run (the manifest's `viewingDirection`): right-to-left books are mirrored, and top-to-bottom or bottom-to-top ones (scrolls, notebooks bound at the top) scrub with up-and-down mouse moves and the Up/Down arrow keys. Light and dark themes follow the OS setting.
- **Accessible (WCAG 2.2 AA).** Arrow keys scrub a focused card, and screen readers announce the page ("Page 12 of 240"). Focus returns to the card when the viewer closes, and the viewer has Previous/Next buttons as well as the slider. There is a skip link, and errors and export progress are announced. Colors pass contrast in light and dark themes, controls are at least 24×24 px, and the layout works at 320px wide. The OS "reduce motion" setting slows flash mode and playback and turns off decorative animation; the title wobble stops after a few seconds, and GIF previews have a pause button. `npm run test:e2e` runs an [axe-core](https://github.com/dequelabs/axe-core) audit of every screen, plus reflow, target-size and keyboard checks.
- **Link previews.** Open Graph and Twitter card metadata, with a share image (`img/social-card.png`).

Expand All @@ -52,7 +52,15 @@ npm run test:e2e # the real app in headless Chromium against a

The end-to-end tests never contact real libraries. They start a local fake IIIF server whose books have blank, text and plate pages, and servers that lack CORS, fail, are slow, or forbid caching. They check scrubbing, politeness (request counts, backoff, caching), exports, collections, drag and drop, share links, touch and reduced motion. Both suites run on every push via `.github/workflows/test.yml`.

`?examples=0` in the URL hides the starter shelf.
### Linking to Flipbook

Add `?iiif-content=` with a manifest or collection URL (or an encoded IIIF Content State) to put that item on the shelf when the page opens, for example from a collection page:

```
https://hadro.github.io/flipbook/?iiif-content=https://heritage.tudelft.nl/iiif/collections/lib-tresor-reuzenarbeid/collection.json
```

A collection shelves its first 36 manifests. `?manifest=` works the same way. Linked items aren't saved to the visitor's own shelf. Add `&examples=0` to hide the starter shelf, so only the linked content shows.

## Deploying

Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
61 changes: 0 additions & 61 deletions baked/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -830,67 +830,6 @@
"baked/archive-org-details-b33498854/23.jpg"
],
"bakedAt": "2026-09-28"
},
"https://collections.library.yale.edu/manifests/33336990": {
"label": "Ferns 1868",
"total": 190,
"rtl": false,
"attribution": "Yale University Library",
"homepage": "https://collections.library.yale.edu/catalog/33336990",
"manifestUrl": "https://collections.library.yale.edu/manifests/33336990",
"pages": [
0,
8,
16,
25,
33,
41,
49,
58,
66,
74,
82,
90,
99,
107,
115,
123,
131,
140,
148,
156,
164,
173,
181,
189
],
"files": [
"baked/collections-library-yale-edu-manifests-33336990/00.jpg",
"baked/collections-library-yale-edu-manifests-33336990/01.jpg",
"baked/collections-library-yale-edu-manifests-33336990/02.jpg",
"baked/collections-library-yale-edu-manifests-33336990/03.jpg",
"baked/collections-library-yale-edu-manifests-33336990/04.jpg",
"baked/collections-library-yale-edu-manifests-33336990/05.jpg",
"baked/collections-library-yale-edu-manifests-33336990/06.jpg",
"baked/collections-library-yale-edu-manifests-33336990/07.jpg",
"baked/collections-library-yale-edu-manifests-33336990/08.jpg",
"baked/collections-library-yale-edu-manifests-33336990/09.jpg",
"baked/collections-library-yale-edu-manifests-33336990/10.jpg",
"baked/collections-library-yale-edu-manifests-33336990/11.jpg",
"baked/collections-library-yale-edu-manifests-33336990/12.jpg",
"baked/collections-library-yale-edu-manifests-33336990/13.jpg",
"baked/collections-library-yale-edu-manifests-33336990/14.jpg",
"baked/collections-library-yale-edu-manifests-33336990/15.jpg",
"baked/collections-library-yale-edu-manifests-33336990/16.jpg",
"baked/collections-library-yale-edu-manifests-33336990/17.jpg",
"baked/collections-library-yale-edu-manifests-33336990/18.jpg",
"baked/collections-library-yale-edu-manifests-33336990/19.jpg",
"baked/collections-library-yale-edu-manifests-33336990/20.jpg",
"baked/collections-library-yale-edu-manifests-33336990/21.jpg",
"baked/collections-library-yale-edu-manifests-33336990/22.jpg",
"baked/collections-library-yale-edu-manifests-33336990/23.jpg"
],
"bakedAt": "2026-09-29"
}
}
}
5 changes: 5 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,11 @@ select {
}
.ticks i.is-loaded, .tick-sample i.is-loaded { background: #8d8272; }
.ticks i.is-current, .tick-sample i.is-current { background: var(--sticker, var(--pink)); }
/* Right-to-left books: page 1 at the right, the way the card scrubs. */
.card-cover[data-dir="rtl"] .ticks { flex-direction: row-reverse; }
/* Pages that run up or down scrub with vertical mouse moves. */
.card-cover[data-dir="ttb"], .card-cover[data-dir="btt"],
.stage-frame[data-dir="ttb"], .stage-frame[data-dir="btt"] { cursor: ns-resize; }
/* Skipped (blank, or text in plates-only mode): a thin dash, unlike any square. */
.ticks i.is-skipped, .tick-sample i.is-skipped { background: linear-gradient(#8d8272, #8d8272) center / 100% 2px no-repeat; }

Expand Down
88 changes: 69 additions & 19 deletions js/app.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
resolveInput,
contentStateManifest,
inputHint,
itemPageFromUrl,
isCollection,
Expand Down Expand Up @@ -386,7 +387,16 @@ class Book {
}

get rtl() {
return this.data ? this.data.rtl : !!(this.baked && this.baked.rtl);
return this.direction === "rtl";
}
/** "ltr", "rtl", "ttb" or "btt": which way the pages run (the manifest's viewingDirection). */
get direction() {
const from = this.data || this.baked;
return (from && (from.direction || (from.rtl ? "rtl" : null))) || "ltr";
}
/** Pages run up or down (a scroll, a top-bound notebook): scrub with vertical mouse moves. */
get vertical() {
return this.direction === "ttb" || this.direction === "btt";
}

load() {
Expand Down Expand Up @@ -466,6 +476,27 @@ const visibility = new IntersectionObserver(
{ rootMargin: "300px" }
);

/**
* Where a pointer sits over a card or the viewer, as 0..1 in reading order.
* Books whose pages run up or down scrub with vertical mouse moves; touch
* always scrubs sideways, since dragging up and down scrolls the page.
*/
function scrubFraction(book, e, r) {
if (book.vertical && e.pointerType === "mouse") {
const f = (e.clientY - r.top) / r.height;
return book.direction === "btt" ? 1 - f : f;
}
const f = (e.clientX - r.left) / r.width;
return book.rtl ? 1 - f : f;
}

/** +1 / -1 for an arrow key that turns the page (mirrored for right-to-left and bottom-to-top), else 0. */
function arrowStep(book, key) {
if (key === "ArrowRight" || key === "ArrowLeft") return (key === "ArrowRight") !== book.rtl ? 1 : -1;
if (book.vertical && (key === "ArrowDown" || key === "ArrowUp")) return (key === "ArrowDown") !== (book.direction === "btt") ? 1 : -1;
return 0;
}

class Card {
constructor(book) {
this.book = book;
Expand Down Expand Up @@ -554,6 +585,7 @@ class Card {
// One frame per pixel of card width at most, so every frame can be reached by the mouse.
const width = Math.round(this.cover.getBoundingClientRect().width) || 0;
this.reel = this.book.reel(density, Math.max(REEL_MAX, width));
this.cover.dataset.dir = this.book.direction;
this.loaded = new Set();
this.ticks.replaceChildren(...this.reel.pages.map(() => document.createElement("i")));
// Anything already loaded (by the viewer, an earlier reel, the baked shelf) counts straight away.
Expand Down Expand Up @@ -660,10 +692,9 @@ class Card {
this.counter.textContent = `p. ${this.reel.pages[j] + 1} / ${this.book.total}${this.platesActive() ? " · plates" : ""}`;
}

/** Scrub to a 0..1 position across the card. */
/** Scrub to a 0..1 position across the card, already in reading order. */
scrubTo(f) {
if (!this.reel) return;
if (this.book.rtl) f = 1 - f;
const n = this.reel.pages.length;
const i = Math.min(n - 1, Math.max(0, Math.floor(f * n)));
this.wanted = i;
Expand Down Expand Up @@ -701,9 +732,8 @@ class Card {
c.addEventListener("pointermove", (e) => {
if (e.pointerType !== "mouse" && startX === null) return;
if (startX !== null && Math.abs(e.clientX - startX) > 8) moved = true;
const r = c.getBoundingClientRect();
c.classList.add("is-scrubbing");
this.scrubTo((e.clientX - r.left) / r.width);
this.scrubTo(scrubFraction(this.book, e, c.getBoundingClientRect()));
});
const leave = () => {
startX = null;
Expand All @@ -725,10 +755,10 @@ class Card {
});
c.addEventListener("keydown", (e) => {
if (!this.reel) return;
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
const step = arrowStep(this.book, e.key);
if (step) {
e.preventDefault();
this.preload();
const step = (e.key === "ArrowRight") !== this.book.rtl ? 1 : -1;
const next = Math.min(this.reel.pages.length - 1, Math.max(0, (this.wanted ?? this.current) + step));
this.wanted = next;
this.show(next);
Expand Down Expand Up @@ -858,7 +888,7 @@ function bookTag(book) {
const SHELF_MAX = 100;
const COLLECTION_MAX = 36;

async function shelveInput(input, { open = false, page = null } = {}) {
async function shelveInput(input, { open = false, page = null, persist = true } = {}) {
input = input.trim();
if (!input) return null;
const existing = [...cards].find((c) => c.book.input === input);
Expand All @@ -881,7 +911,7 @@ async function shelveInput(input, { open = false, page = null } = {}) {
track(`paste/fail/${hostOf(candidates[0]) || "unknown"}`);
return null;
}
if (isCollection(fetched.json)) return shelveCollection(fetched);
if (isCollection(fetched.json)) return shelveCollection(fetched, { persist });

const book = new Book({ input, removable: true });
try {
Expand All @@ -893,15 +923,15 @@ async function shelveInput(input, { open = false, page = null } = {}) {
}
const card = addCard(book, { prepend: true, animate: true });
track(`paste/ok/${hostOf(book.manifestUrl) || "unknown"}`);
writeShelf([input, ...readShelf().filter((x) => x !== input)].slice(0, SHELF_MAX));
if (persist) writeShelf([input, ...readShelf().filter((x) => x !== input)].slice(0, SHELF_MAX));
setStatus(`Shelved "${book.title}" (${book.data.pages.length} images). Hover it!`);
card.el.scrollIntoView({ behavior: "smooth", block: "center" });
if (open) openViewer(book, { page });
return card;
}

/** A IIIF Collection: shelve its first COLLECTION_MAX manifests as separate books. */
function shelveCollection({ url, json }) {
function shelveCollection({ url, json }, { persist = true } = {}) {
const { label, manifests, subCollections } = collectionMembers(json);
track(`paste/collection/${hostOf(url) || "unknown"}`);
if (!manifests.length) {
Expand All @@ -919,7 +949,7 @@ function shelveCollection({ url, json }) {
for (const m of [...picked].reverse()) {
first = addCard(new Book({ input: m.id, title: m.label, removable: true }), { prepend: true, animate: true });
}
writeShelf([...picked.map((m) => m.id), ...readShelf().filter((x) => !picked.some((m) => m.id === x))].slice(0, SHELF_MAX));
if (persist) writeShelf([...picked.map((m) => m.id), ...readShelf().filter((x) => !picked.some((m) => m.id === x))].slice(0, SHELF_MAX));
const more = manifests.length > COLLECTION_MAX ? ` (the first ${COLLECTION_MAX} of ${manifests.length})` : "";
setStatus(`Shelved ${picked.length} books from the collection "${label}"${more}. Hover away!`);
if (first) first.el.scrollIntoView({ behavior: "smooth", block: "center" });
Expand Down Expand Up @@ -1044,6 +1074,9 @@ function nearestAvailable(i) {
function setFrames(pages, keepPage) {
V.frames = pages;
range.max = String(Math.max(0, pages.length - 1));
// Match the slider (and the stage's cursor) to the way the pages run.
range.dir = V.book.rtl ? "rtl" : "ltr";
stageFrame.dataset.dir = V.book.direction;
let i = 0;
if (keepPage != null) {
i = pages.findIndex((p) => p >= keepPage);
Expand All @@ -1066,6 +1099,9 @@ async function openViewer(book, { page = null } = {}) {
V.book = book;
V.live = false;
V.dir = 1;
// Forget the last book's frames, or its page would carry over as "where the visitor was".
V.frames = [];
V.wanted = 0;
V.bakedMap = new Map(book.baked ? book.baked.pages.map((p, k) => [p, book.baked.files[k]]) : []);

const src = SOURCES[book.source] || SOURCES.mine;
Expand Down Expand Up @@ -1259,9 +1295,7 @@ range.addEventListener("input", () => {
stageFrame.addEventListener("pointermove", (e) => {
if (e.pointerType !== "mouse" && !dragging) return;
if (!V.frames.length) return;
const r = stageFrame.getBoundingClientRect();
let f = (e.clientX - r.left) / r.width;
if (V.book.rtl) f = 1 - f;
const f = scrubFraction(V.book, e, stageFrame.getBoundingClientRect());
const i = Math.min(V.frames.length - 1, Math.max(0, Math.floor(f * V.frames.length)));
if (i === V.wanted) return;
stopPlay();
Expand All @@ -1271,12 +1305,12 @@ range.addEventListener("input", () => {

viewer.addEventListener("keydown", (e) => {
if (e.target.matches("input[type=text], select")) return;
const rtl = V.book && V.book.rtl;
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
if (e.target === range) return; // native range handles it
const step = V.book && arrowStep(V.book, e.key);
if (step) {
if (e.target === range && (e.key === "ArrowRight" || e.key === "ArrowLeft")) return; // native range handles it
e.preventDefault();
stopPlay();
viewerStep((e.key === "ArrowRight") !== rtl ? 1 : -1);
viewerStep(step);
announceViewerPage();
} else if (e.key === " " && !e.target.matches("button, a")) {
e.preventDefault();
Expand Down Expand Up @@ -1523,6 +1557,21 @@ function openFromHash() {
else shelveInput(input, { open: true, page });
}

/**
* Links like ?iiif-content=<manifest or collection URL> (or ?manifest=…) put
* that item on the shelf, first in line. The IIIF Content State spec's
* parameter name, so it also takes an encoded content state. Nothing is saved
* to the visitor's own shelf unless they paste it themselves.
*/
async function shelveFromQuery() {
const params = new URLSearchParams(location.search);
const raw = (params.get("iiif-content") || params.get("manifest") || "").trim();
if (!raw) return;
const input = /^https?:\/\//.test(raw) ? raw : contentStateManifest(raw) || raw;
track("link/iiif-content");
await shelveInput(input, { persist: false });
}

// Long-lived image cache for returning visitors (see sw.js). Optional.
if ("serviceWorker" in navigator && location.protocol !== "file:") {
navigator.serviceWorker.register("sw.js").catch(() => {});
Expand All @@ -1535,4 +1584,5 @@ for (const input of readShelf()) addCard(new Book({ input, removable: true }));
if (new URLSearchParams(location.search).get("examples") !== "0") {
for (const ex of EXAMPLES) addCard(new Book(ex));
}
shelveFromQuery();
openFromHash();
8 changes: 4 additions & 4 deletions js/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export const EXAMPLES = [
input: "https://www.loc.gov/item/2021667837/",

},
{
{
// Title comes from the manifest.
source: "yale",
title: "Ferns",
note: "Nature-printed plates · 1868",
input: "https://collections.library.yale.edu/manifests/33336990",
note: "Yale University Library",
input: "https://collections.library.yale.edu/manifests/2037182",
},
{
source: "ia",
Expand Down
23 changes: 21 additions & 2 deletions js/iiif.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,15 @@ export async function fetchFirstManifest(candidates, { signal } = {}) {
try {
// Give up on a manifest after 30 s rather than leaving a card loading forever.
const timeout = typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(30000) : undefined;
const res = await fetch(url, { signal: signal || timeout, headers: { Accept: "application/ld+json, application/json" } });
const opts = { signal: signal || timeout, redirect: "follow" };
let res = await fetch(url, { ...opts, headers: { Accept: "application/ld+json, application/json" } });
// Some resolvers (ARK and handle services, notably) only redirect a
// browser-style request and answer 404/406 when asked for JSON: ask
// again the way a plain link would.
if (!res.ok && res.status >= 400 && res.status < 500) {
const plain = await fetch(url, opts).catch(() => null);
if (plain && plain.ok) res = plain;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return { url, json };
Expand Down Expand Up @@ -348,7 +356,7 @@ export async function followToManifest(first, fetchJson) {
return { url, json, part };
}

/** Flatten a v2 or v3 manifest into {label, pages[], rtl, attribution}. */
/** Flatten a v2 or v3 manifest into {label, pages[], direction, rtl, attribution}. */
export function parseManifest(json) {
if (!json || typeof json !== "object") throw new Error("Not a JSON object");
const type = json.type || json["@type"];
Expand Down Expand Up @@ -382,11 +390,22 @@ export function parseManifest(json) {
label: labelText(json.label) || "Untitled",
homepage: (home && (typeof home === "string" ? home : idOf(home))) || null,
pages,
direction: viewingDirection(dir),
rtl: /right-to-left/.test(dir),
attribution: attribution.replace(/<[^>]+>/g, "").trim(),
};
}

/**
* The manifest's viewingDirection as "ltr" (the default), "rtl", "ttb" or
* "btt". Canvases are always listed in reading order; this only says which
* way the pages run.
*/
export function viewingDirection(value) {
const m = String(value || "").match(/(left-to-right|right-to-left|top-to-bottom|bottom-to-top)/);
return m ? { "left-to-right": "ltr", "right-to-left": "rtl", "top-to-bottom": "ttb", "bottom-to-top": "btt" }[m[1]] : "ltr";
}

// ---------------------------------------------------------------------------
// Image URLs
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading