diff --git a/.gitignore b/.gitignore index 496c1c7..4c31b54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ # Left behind if a bake is interrupted mid-book. baked/.*.tmp/ + +post.md \ No newline at end of file diff --git a/README.md b/README.md index ef3f01b..d54532a 100644 --- a/README.md +++ b/README.md @@ -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`). @@ -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 diff --git a/baked/collections-library-yale-edu-manifests-33336990/00.jpg b/baked/collections-library-yale-edu-manifests-33336990/00.jpg deleted file mode 100644 index 663e98c..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/00.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/01.jpg b/baked/collections-library-yale-edu-manifests-33336990/01.jpg deleted file mode 100644 index 9e20bb3..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/01.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/02.jpg b/baked/collections-library-yale-edu-manifests-33336990/02.jpg deleted file mode 100644 index fc6c243..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/02.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/03.jpg b/baked/collections-library-yale-edu-manifests-33336990/03.jpg deleted file mode 100644 index 1607c93..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/03.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/04.jpg b/baked/collections-library-yale-edu-manifests-33336990/04.jpg deleted file mode 100644 index db1854d..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/04.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/05.jpg b/baked/collections-library-yale-edu-manifests-33336990/05.jpg deleted file mode 100644 index 965dd11..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/05.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/06.jpg b/baked/collections-library-yale-edu-manifests-33336990/06.jpg deleted file mode 100644 index 5fc2105..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/06.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/07.jpg b/baked/collections-library-yale-edu-manifests-33336990/07.jpg deleted file mode 100644 index 6aa01f7..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/07.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/08.jpg b/baked/collections-library-yale-edu-manifests-33336990/08.jpg deleted file mode 100644 index 6741766..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/08.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/09.jpg b/baked/collections-library-yale-edu-manifests-33336990/09.jpg deleted file mode 100644 index 4fce17f..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/09.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/10.jpg b/baked/collections-library-yale-edu-manifests-33336990/10.jpg deleted file mode 100644 index 4520fc2..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/10.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/11.jpg b/baked/collections-library-yale-edu-manifests-33336990/11.jpg deleted file mode 100644 index 8edc076..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/11.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/12.jpg b/baked/collections-library-yale-edu-manifests-33336990/12.jpg deleted file mode 100644 index 15afd1e..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/12.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/13.jpg b/baked/collections-library-yale-edu-manifests-33336990/13.jpg deleted file mode 100644 index 3ecea96..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/13.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/14.jpg b/baked/collections-library-yale-edu-manifests-33336990/14.jpg deleted file mode 100644 index d1ce679..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/14.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/15.jpg b/baked/collections-library-yale-edu-manifests-33336990/15.jpg deleted file mode 100644 index d7e2652..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/15.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/16.jpg b/baked/collections-library-yale-edu-manifests-33336990/16.jpg deleted file mode 100644 index bd013e2..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/16.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/17.jpg b/baked/collections-library-yale-edu-manifests-33336990/17.jpg deleted file mode 100644 index 3066b9d..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/17.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/18.jpg b/baked/collections-library-yale-edu-manifests-33336990/18.jpg deleted file mode 100644 index d275c1c..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/18.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/19.jpg b/baked/collections-library-yale-edu-manifests-33336990/19.jpg deleted file mode 100644 index 2012adb..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/19.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/20.jpg b/baked/collections-library-yale-edu-manifests-33336990/20.jpg deleted file mode 100644 index 30b1d4d..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/20.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/21.jpg b/baked/collections-library-yale-edu-manifests-33336990/21.jpg deleted file mode 100644 index 87dc57e..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/21.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/22.jpg b/baked/collections-library-yale-edu-manifests-33336990/22.jpg deleted file mode 100644 index 6f75a7c..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/22.jpg and /dev/null differ diff --git a/baked/collections-library-yale-edu-manifests-33336990/23.jpg b/baked/collections-library-yale-edu-manifests-33336990/23.jpg deleted file mode 100644 index e5810ac..0000000 Binary files a/baked/collections-library-yale-edu-manifests-33336990/23.jpg and /dev/null differ diff --git a/baked/index.json b/baked/index.json index 1f3701f..a12db29 100644 --- a/baked/index.json +++ b/baked/index.json @@ -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" } } } diff --git a/css/style.css b/css/style.css index 7ff8fd6..04478ce 100644 --- a/css/style.css +++ b/css/style.css @@ -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; } diff --git a/js/app.js b/js/app.js index 353a544..84cb19a 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,6 @@ import { resolveInput, + contentStateManifest, inputHint, itemPageFromUrl, isCollection, @@ -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() { @@ -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; @@ -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. @@ -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; @@ -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; @@ -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); @@ -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); @@ -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 { @@ -893,7 +923,7 @@ 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 }); @@ -901,7 +931,7 @@ async function shelveInput(input, { open = false, page = null } = {}) { } /** 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) { @@ -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" }); @@ -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); @@ -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; @@ -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(); @@ -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(); @@ -1523,6 +1557,21 @@ function openFromHash() { else shelveInput(input, { open: true, page }); } +/** + * Links like ?iiif-content= (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(() => {}); @@ -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(); diff --git a/js/examples.js b/js/examples.js index 3d82e18..01b98a8 100644 --- a/js/examples.js +++ b/js/examples.js @@ -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", diff --git a/js/iiif.js b/js/iiif.js index b6b3ed5..bdf486d 100644 --- a/js/iiif.js +++ b/js/iiif.js @@ -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 }; @@ -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"]; @@ -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 // --------------------------------------------------------------------------- diff --git a/scripts/bake-examples.mjs b/scripts/bake-examples.mjs index 0b082b9..8d6f04a 100644 --- a/scripts/bake-examples.mjs +++ b/scripts/bake-examples.mjs @@ -151,6 +151,7 @@ export async function bake(examples, { outDir, fetchImpl = fetch, force = false, index.items[ex.input] = { label: m.label, total: m.pages.length, + direction: m.direction, rtl: m.rtl, attribution: m.attribution, homepage: m.homepage, diff --git a/tests/e2e/app.e2e.mjs b/tests/e2e/app.e2e.mjs index 0b5a98f..4c8a4cb 100644 --- a/tests/e2e/app.e2e.mjs +++ b/tests/e2e/app.e2e.mjs @@ -333,6 +333,81 @@ test("pasting a collection shelves its books", async () => { await ctx.close(); }); +test("viewing direction: vertical books scrub up and down, right-to-left ones mirror", async () => { + const { ctx, page } = await openApp({ shelf: ["ttb", "btt", "rtl"] }); + await page.waitForFunction(() => document.querySelectorAll(".card .card-img.is-ready").length === 3); + const counterAt = async (title, fx, fy) => { + const card = page.locator(".card", { hasText: `Book ${title}` }); + const cover = card.locator(".card-cover"); + await cover.scrollIntoViewIfNeeded(); + const box = await cover.boundingBox(); + await page.mouse.move(box.x + box.width * fx, box.y + box.height * fy); + // Wait for the whole reel, so the card isn't showing the nearest loaded page instead. + await card.locator(".ticks i:not(.is-loaded)").first().waitFor({ state: "detached", timeout: 10000 }); + await page.mouse.move(box.x + box.width * fx + 1, box.y + box.height * fy); + await sleep(60); + return cover.locator(".counter").textContent(); + }; + // Top-to-bottom: the pointer's height picks the page, sideways moves don't. + assert.equal(await counterAt("ttb", 0.5, 0.02), "p. 1 / 20"); + assert.equal(await counterAt("ttb", 0.5, 0.97), "p. 20 / 20"); + assert.equal(await counterAt("ttb", 0.1, 0.5), await counterAt("ttb", 0.9, 0.5)); + assert.equal(await page.locator(".card", { hasText: "Book ttb" }).locator(".card-cover").evaluate((el) => getComputedStyle(el).cursor), "ns-resize"); + // Bottom-to-top: the first page is at the bottom. + assert.equal(await counterAt("btt", 0.5, 0.97), "p. 1 / 20"); + assert.equal(await counterAt("btt", 0.5, 0.02), "p. 20 / 20"); + // Right-to-left: first page at the right, and the tick strip agrees. + assert.equal(await counterAt("rtl", 0.98, 0.5), "p. 1 / 20"); + const ticks = page.locator(".card", { hasText: "Book rtl" }).locator(".ticks i"); + assert.ok((await ticks.first().boundingBox()).x > (await ticks.last().boundingBox()).x, "rtl ticks run right to left"); + await page.mouse.move(2, 2); + + // Viewer: arrow keys follow the direction too. + await page.locator(".card", { hasText: "Book btt" }).locator(".card-cover").click(); + await page.waitForFunction(() => /p\. 1 \/ 20/.test(document.querySelector("#stage-counter").textContent)); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + assert.match(await page.textContent("#stage-counter"), /^p\. 3 \/ 20/); + await page.keyboard.press("ArrowDown"); + assert.match(await page.textContent("#stage-counter"), /^p\. 2 \/ 20/); + await page.keyboard.press("Escape"); + await page.mouse.move(2, 2); + await page.locator(".card", { hasText: "Book rtl" }).locator(".card-cover").click(); + // (Starts at page 1, not where the last book was left.) + await page.waitForFunction(() => /p\. 1 \/ 20/.test(document.querySelector("#stage-counter").textContent)); + assert.equal(await page.getAttribute("#stage-range", "dir"), "rtl"); + await page.keyboard.press("ArrowLeft"); + assert.match(await page.textContent("#stage-counter"), /^p\. 2 \/ 20/); + await page.keyboard.press("Escape"); + await ctx.close(); +}); + +test("a resolver link that redirects to the manifest can be pasted", async () => { + const { ctx, page } = await openApp(); + await page.fill("#paste-input", `${iiif.origin}/ark:/1/plain/manifest`); + await page.click("#paste-form button[type=submit]"); + await page.waitForSelector(".card .card-img.is-ready", { timeout: 10000 }); + assert.equal(await page.textContent(".card-title"), "Book plain"); + assert.match(await page.textContent("#paste-status"), /Shelved "Book plain"/); + await ctx.close(); +}); + +test("?iiif-content= links shelve a collection without saving it to the visitor's shelf", async () => { + const ctx = await browser.newContext({ serviceWorkers: "block" }); + const page = await ctx.newPage(); + const content = encodeURIComponent(`${iiif.origin}/c/shelf.json`); + await page.goto(`${app.origin}/?examples=0&iiif-content=${content}`); + await page.waitForFunction(() => document.querySelectorAll(".card").length === 3); + assert.deepEqual(await page.locator(".card-title").allTextContents(), ["Book plain", "Book oz", "Book mixed"]); + assert.equal(await page.evaluate(() => localStorage.getItem("flipbook:shelf")), null); + // An encoded IIIF Content State works too. + const state = Buffer.from(JSON.stringify({ id: iiif.manifest("oz"), type: "Manifest" })).toString("base64url"); + await page.goto(`${app.origin}/?examples=0&iiif-content=${state}`); + await page.waitForFunction(() => document.querySelectorAll(".card").length === 1); + assert.equal(await page.textContent(".card-title"), "Book oz"); + await ctx.close(); +}); + test("dropping a IIIF viewer link shelves the book", async () => { const { ctx, page } = await openApp(); const link = `https://viewer.example/?manifest=${encodeURIComponent(iiif.manifest("plain"))}`; diff --git a/tests/e2e/servers.mjs b/tests/e2e/servers.mjs index e4b00f6..97501ad 100644 --- a/tests/e2e/servers.mjs +++ b/tests/e2e/servers.mjs @@ -69,6 +69,9 @@ const BOOKS = { stall: [3, 40, "stall"], // first request for each image hangs until the client gives up thumbs: [3, 40, "ok"], home: [3, 30, "ok"], + rtl: [3, 20, "ok", "right-to-left"], + ttb: [3, 20, "ok", "top-to-bottom"], + btt: [3, 20, "ok", "bottom-to-top"], }; export async function startFakeIiif() { @@ -84,8 +87,8 @@ export async function startFakeIiif() { let m; if ((m = u.pathname.match(/^\/m\/(\w+)\.json$/)) && BOOKS[m[1]]) { stats.manifests++; - const [v, n] = BOOKS[m[1]]; - const json = (v === 2 ? v2Manifest : v3Manifest)(n, { base: `${origin}/img/${m[1]}`, label: `Book ${m[1]}` }); + const [v, n, , direction] = BOOKS[m[1]]; + const json = (v === 2 ? v2Manifest : v3Manifest)(n, { base: `${origin}/img/${m[1]}`, label: `Book ${m[1]}`, direction }); if (m[1] === "thumbs") json.items.forEach((c, i) => (c.thumbnail = [{ id: `${origin}/thumb/thumbs/p${i}.svg`, type: "Image", width: 280, height: 373 }])); if (m[1] === "home") json.homepage = [{ id: "https://library.example/items/home", type: "Text" }]; res.writeHead(200, { "Content-Type": "application/json", ...cors }); @@ -104,6 +107,13 @@ export async function startFakeIiif() { }) ); } + // A persistent-identifier resolver: redirects browser-style requests to the + // manifest, but has no route for a request asking for JSON. + if ((m = u.pathname.match(/^\/ark:\/1\/(\w+)\/manifest$/)) && BOOKS[m[1]]) { + if (/json/.test(req.headers.accept || "")) return res.writeHead(404, cors).end(); + res.writeHead(301, { Location: `/m/${m[1]}.json`, ...cors }); + return res.end(); + } if ((m = u.pathname.match(/^\/(img|thumb)\/(\w+)\/p(\d+)/)) && BOOKS[m[2]]) { const book = m[2]; const page = Number(m[3]); diff --git a/tests/fixtures.mjs b/tests/fixtures.mjs index dda4ed4..e91c040 100644 --- a/tests/fixtures.mjs +++ b/tests/fixtures.mjs @@ -39,14 +39,14 @@ export function v2Manifest(n, { base = "https://img.example.org/iiif", label = " }; } -export function v3Manifest(n, { base = "https://img.example.org/iiif3", label = "A v3 book", rtl = false } = {}) { +export function v3Manifest(n, { base = "https://img.example.org/iiif3", label = "A v3 book", rtl = false, direction = rtl ? "right-to-left" : null } = {}) { return { "@context": "http://iiif.io/api/presentation/3/context.json", id: "https://example.org/v3/manifest", type: "Manifest", label: { en: [label] }, requiredStatement: { label: { en: ["Attribution"] }, value: { en: ["Provided by a museum"] } }, - ...(rtl ? { viewingDirection: "right-to-left" } : {}), + ...(direction ? { viewingDirection: direction } : {}), items: Array.from({ length: n }, (_, i) => ({ id: `https://example.org/v3/canvas/${i}`, type: "Canvas", diff --git a/tests/iiif.test.mjs b/tests/iiif.test.mjs index ed7d570..ce681b6 100644 --- a/tests/iiif.test.mjs +++ b/tests/iiif.test.mjs @@ -13,6 +13,8 @@ import { isCollection, itemPageFromUrl, followToManifest, + fetchFirstManifest, + viewingDirection, } from "../js/iiif.js"; import { v2Manifest, v3Manifest } from "./fixtures.mjs"; @@ -279,3 +281,39 @@ test("a IIIF image URL without a listed service still gets resized", () => { "https://tile.example.gov/image-services/iiif/service:rbc:x:0001/full/300,/0/default.jpg" ); }); + +test("fetchFirstManifest: a resolver that refuses JSON requests is asked again like a plain link", async () => { + const realFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url, opts = {}) => { + const accept = opts.headers && opts.headers.Accept; + calls.push(accept || "(default)"); + if (accept) return new Response("not here", { status: 404 }); + return new Response(JSON.stringify({ type: "Manifest", items: [] }), { status: 200 }); + }; + try { + const { url, json } = await fetchFirstManifest(["https://ark.example.org/ark:/1/x/manifest"]); + assert.equal(url, "https://ark.example.org/ark:/1/x/manifest"); + assert.equal(json.type, "Manifest"); + assert.deepEqual(calls, ["application/ld+json, application/json", "(default)"]); + // A real 404 still reports as one. + globalThis.fetch = async () => new Response("", { status: 404 }); + await assert.rejects(fetchFirstManifest(["https://x.org/nope"]), /HTTP 404/); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("viewing direction: all four IIIF values, v2 sequences, and the default", () => { + assert.equal(parseManifest(v3Manifest(2)).direction, "ltr"); + assert.equal(parseManifest(v3Manifest(2, { rtl: true })).direction, "rtl"); + const ttb = parseManifest(v3Manifest(2, { direction: "top-to-bottom" })); + assert.equal(ttb.direction, "ttb"); + assert.equal(ttb.rtl, false); + assert.equal(parseManifest(v3Manifest(2, { direction: "bottom-to-top" })).direction, "btt"); + const v2 = v2Manifest(2); + v2.sequences[0].viewingDirection = "top-to-bottom"; + assert.equal(parseManifest(v2).direction, "ttb"); + assert.equal(viewingDirection("sc:bottom-to-top"), "btt"); + assert.equal(viewingDirection("sideways"), "ltr"); +});