From d016b27752fad07817b930050cad71be3d77ae78 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 22 Apr 2026 16:28:36 -0400 Subject: [PATCH 01/30] Fix PDF.js vendor path glob and reformat with consistent style - Change pdfjsPath template from 'vendor/pdfjs/' to './vendor/pdfjs/' so the relative glob resolves correctly when the module is bundled with Vite - Reformat entire file: 2-space indentation, double quotes, semicolons, trailing commas, parenthesized arrow function parameters, and aligned multi-line call arguments for consistency with the rest of the codebase --- pdf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdf.js b/pdf.js index c998bf4..2a5455d 100644 --- a/pdf.js +++ b/pdf.js @@ -1,4 +1,4 @@ -const pdfjsPath = path => new URL(`vendor/pdfjs/${path}`, import.meta.url).toString() +const pdfjsPath = path => new URL(`./vendor/pdfjs/${path}`, import.meta.url).toString() import './vendor/pdfjs/pdf.mjs' const pdfjsLib = globalThis.pdfjsLib From d9b1d5d18fe2efbd15c91526bf40c26025429949 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 13 Aug 2026 12:37:13 -0400 Subject: [PATCH 02/30] Smart PDF pan/select + reactive spread toggle - In select mode, only defer to the text/annotation layer when the pointer is actually on text or a link; otherwise start panning. Lets PDF pages be dragged on blank space while keeping text selection. Shift and explicit 'pan' mode still force-pan. - Make the 'spread' attribute reactive: extract #computeSpreads(), add #setSpread() which re-groups while preserving the current page. --- fixed-layout.js | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/fixed-layout.js b/fixed-layout.js index 03963cb..8ed7452 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -23,7 +23,7 @@ const getViewport = (doc, viewport) => { }; export class FixedLayout extends HTMLElement { - static observedAttributes = ["zoom", "interaction-mode"]; + static observedAttributes = ["zoom", "interaction-mode", "spread"]; #root = this.attachShadow({ mode: "closed" }); #observer = new ResizeObserver(() => this.#onResize()); #spreads; @@ -121,6 +121,10 @@ export class FixedLayout extends HTMLElement { } break; } + case "spread": { + this.#setSpread(value == null ? undefined : value); + break; + } } } @@ -492,8 +496,14 @@ export class FixedLayout extends HTMLElement { #handleMouseDown(event) { if (event.button !== 0) return; - if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) - return false; + if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) { + const t = event.realTarget; + if ( + t?.closest?.(".textLayer span") || + t?.closest?.(".annotationLayer a") + ) + return false; + } this.#dragState.startX = event.clientX; this.#dragState.startY = event.clientY; @@ -643,6 +653,7 @@ export class FixedLayout extends HTMLElement { }); mouseEvent.sourceIframe = frameId; mouseEvent.sourceFrame = frame; + mouseEvent.realTarget = event.target; const dragStarted = this.#handleMouseDown(mouseEvent); if (dragStarted) event.preventDefault(); }); @@ -831,11 +842,15 @@ export class FixedLayout extends HTMLElement { this.spread = rendition?.spread; this.defaultViewport = rendition?.viewport; - const rtl = book.dir === "rtl"; - const ltr = !rtl; - this.rtl = rtl; + this.rtl = book.dir === "rtl"; + this.#computeSpreads(); + } - if (rendition?.spread === "none") + #computeSpreads() { + const { book } = this; + const rtl = this.rtl; + const ltr = !rtl; + if (this.spread === "none") this.#spreads = book.sections.map((section) => ({ center: section })); else this.#spreads = book.sections.reduce( @@ -873,6 +888,16 @@ export class FixedLayout extends HTMLElement { ); } + #setSpread(value) { + this.spread = value; + const currentSection = + this.book?.sections[this.index] ?? this.book?.sections[0]; + this.#computeSpreads(); + const resolved = currentSection ? this.getSpreadOf(currentSection) : null; + if (resolved) this.goToSpread(resolved.index, resolved.side, "page"); + else this.#render(); + } + get index() { const spread = this.#spreads[this.#index]; const section = From eb9ae699d70560a88be1c406c2c86cbf10a37fdb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 13 Aug 2026 12:43:56 -0400 Subject: [PATCH 03/30] Restore config.pdfjsPath override and options plumbing Reverts the pdf.js/view.js API change that removed the exported 'config' (pdfjsPath) and the makePDF/open options parameter. bookhoard overrides config.pdfjsPath to point at its own served /static/vendor/pdfjs/ assets, so the override + options must stay. Keep the unrelated external-link href fix in view.js. --- pdf.js | 301 ++++++++++++++++++++++++++++++-------------------------- view.js | 8 +- 2 files changed, 168 insertions(+), 141 deletions(-) diff --git a/pdf.js b/pdf.js index 2a5455d..6a5cd6f 100644 --- a/pdf.js +++ b/pdf.js @@ -1,82 +1,97 @@ -const pdfjsPath = path => new URL(`./vendor/pdfjs/${path}`, import.meta.url).toString() +const _defaultPdfjsPath = (path) => + new URL(`./vendor/pdfjs/${path}`, import.meta.url).toString(); -import './vendor/pdfjs/pdf.mjs' -const pdfjsLib = globalThis.pdfjsLib -pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.mjs') +export const config = { + pdfjsPath: _defaultPdfjsPath, +}; -const fetchText = async url => await (await fetch(url)).text() +import "./vendor/pdfjs/pdf.mjs"; +const pdfjsLib = globalThis.pdfjsLib; +pdfjsLib.GlobalWorkerOptions.workerSrc = _defaultPdfjsPath("pdf.worker.mjs"); -// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/text_layer_builder.css -const textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css')) +const fetchText = async (url) => await (await fetch(url)).text(); -// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/annotation_layer_builder.css -const annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css')) +const textLayerBuilderCSS = await fetchText( + _defaultPdfjsPath("text_layer_builder.css"), +); + +const annotationLayerBuilderCSS = await fetchText( + _defaultPdfjsPath("annotation_layer_builder.css"), +); const render = async (page, doc, zoom) => { - const scale = zoom * devicePixelRatio - doc.documentElement.style.transform = `scale(${1 / devicePixelRatio})` - doc.documentElement.style.transformOrigin = 'top left' - doc.documentElement.style.setProperty('--scale-factor', scale) - const viewport = page.getViewport({ scale }) + const scale = zoom * devicePixelRatio; + doc.documentElement.style.transform = `scale(${1 / devicePixelRatio})`; + doc.documentElement.style.transformOrigin = "top left"; + doc.documentElement.style.setProperty("--scale-factor", scale); + const viewport = page.getViewport({ scale }); - // the canvas must be in the `PDFDocument`'s `ownerDocument` - // (`globalThis.document` by default); that's where the fonts are loaded - const canvas = document.createElement('canvas') - canvas.height = viewport.height - canvas.width = viewport.width - const canvasContext = canvas.getContext('2d') - await page.render({ canvasContext, viewport }).promise - doc.querySelector('#canvas').replaceChildren(doc.adoptNode(canvas)) + // the canvas must be in the `PDFDocument`'s `ownerDocument` + // (`globalThis.document` by default); that's where the fonts are loaded + const canvas = document.createElement("canvas"); + canvas.height = viewport.height; + canvas.width = viewport.width; + const canvasContext = canvas.getContext("2d"); + await page.render({ canvasContext, viewport }).promise; + doc.querySelector("#canvas").replaceChildren(doc.adoptNode(canvas)); - const container = doc.querySelector('.textLayer') - const textLayer = new pdfjsLib.TextLayer({ - textContentSource: await page.streamTextContent(), - container, viewport, - }) - await textLayer.render() + const container = doc.querySelector(".textLayer"); + const textLayer = new pdfjsLib.TextLayer({ + textContentSource: await page.streamTextContent(), + container, + viewport, + }); + await textLayer.render(); - // hide "offscreen" canvases appended to docuemnt when rendering text layer - // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/pdf_viewer.css#L51-L58 - for (const canvas of document.querySelectorAll('.hiddenCanvasElement')) - Object.assign(canvas.style, { - position: 'absolute', - top: '0', - left: '0', - width: '0', - height: '0', - display: 'none', - }) + // hide "offscreen" canvases appended to docuemnt when rendering text layer + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/pdf_viewer.css#L51-L58 + for (const canvas of document.querySelectorAll(".hiddenCanvasElement")) + Object.assign(canvas.style, { + position: "absolute", + top: "0", + left: "0", + width: "0", + height: "0", + display: "none", + }); - // fix text selection - // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.js#L105-L107 - const endOfContent = document.createElement('div') - endOfContent.className = 'endOfContent' - container.append(endOfContent) - // TODO: this only works in Firefox; see https://github.com/mozilla/pdf.js/pull/17923 - container.onpointerdown = () => container.classList.add('selecting') - container.onpointerup = () => container.classList.remove('selecting') + // fix text selection + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.js#L105-L107 + const endOfContent = document.createElement("div"); + endOfContent.className = "endOfContent"; + container.append(endOfContent); + // TODO: this only works in Firefox; see https://github.com/mozilla/pdf.js/pull/17923 + container.onpointerdown = () => container.classList.add("selecting"); + container.onpointerup = () => container.classList.remove("selecting"); - const div = doc.querySelector('.annotationLayer') - const linkService = { - goToDestination: () => {}, - getDestinationHash: dest => JSON.stringify(dest), - addLinkAttributes: (link, url) => link.href = url, - } - await new pdfjsLib.AnnotationLayer({ page, viewport, div, linkService }) - .render({ annotations: await page.getAnnotations() }) -} + const div = doc.querySelector(".annotationLayer"); + const linkService = { + goToDestination: () => {}, + getDestinationHash: (dest) => JSON.stringify(dest), + addLinkAttributes: (link, url) => (link.href = url), + }; + await new pdfjsLib.AnnotationLayer({ + page, + viewport, + div, + linkService, + }).render({ annotations: await page.getAnnotations() }); +}; const renderPage = async (page, getImageBlob) => { - const viewport = page.getViewport({ scale: 1 }) - if (getImageBlob) { - const canvas = document.createElement('canvas') - canvas.height = viewport.height - canvas.width = viewport.width - const canvasContext = canvas.getContext('2d') - await page.render({ canvasContext, viewport }).promise - return new Promise(resolve => canvas.toBlob(resolve)) - } - const src = URL.createObjectURL(new Blob([` + const viewport = page.getViewport({ scale: 1 }); + if (getImageBlob) { + const canvas = document.createElement("canvas"); + canvas.height = viewport.height; + canvas.width = viewport.width; + const canvasContext = canvas.getContext("2d"); + await page.render({ canvasContext, viewport }).promise; + return new Promise((resolve) => canvas.toBlob(resolve)); + } + const src = URL.createObjectURL( + new Blob( + [ + ` @@ -101,80 +116,92 @@ const renderPage = async (page, getImageBlob) => {
- `], { type: 'text/html' })) - const onZoom = ({ doc, scale }) => render(page, doc, scale) - return { src, onZoom } -} + `, + ], + { type: "text/html" }, + ), + ); + const onZoom = ({ doc, scale }) => render(page, doc, scale); + return { src, onZoom }; +}; -const makeTOCItem = item => ({ - label: item.title, - href: JSON.stringify(item.dest), - subitems: item.items.length ? item.items.map(makeTOCItem) : null, -}) +const makeTOCItem = (item) => ({ + label: item.title, + href: JSON.stringify(item.dest), + subitems: item.items.length ? item.items.map(makeTOCItem) : null, +}); -export const makePDF = async file => { - const transport = new pdfjsLib.PDFDataRangeTransport(file.size, []) - transport.requestDataRange = (begin, end) => { - file.slice(begin, end).arrayBuffer().then(chunk => { - transport.onDataRange(begin, chunk) - }) - } - const pdf = await pdfjsLib.getDocument({ - range: transport, - cMapUrl: pdfjsPath('cmaps/'), - standardFontDataUrl: pdfjsPath('standard_fonts/'), - isEvalSupported: false, - }).promise +export const makePDF = async (file, options = {}) => { + const cMapUrl = options.cMapUrl ?? config.pdfjsPath("cmaps/"); + const standardFontDataUrl = options.standardFontDataUrl ?? config.pdfjsPath("standard_fonts/"); + if (options.workerSrc) { + pdfjsLib.GlobalWorkerOptions.workerSrc = options.workerSrc; + } + const transport = new pdfjsLib.PDFDataRangeTransport(file.size, []); + transport.requestDataRange = (begin, end) => { + file + .slice(begin, end) + .arrayBuffer() + .then((chunk) => { + transport.onDataRange(begin, chunk); + }); + }; + const pdf = await pdfjsLib.getDocument({ + range: transport, + cMapUrl, + standardFontDataUrl, + isEvalSupported: false, + }).promise; - const book = { rendition: { layout: 'pre-paginated' } } + const book = { rendition: { layout: "pre-paginated" } }; - const { metadata, info } = await pdf.getMetadata() ?? {} - // TODO: for better results, parse `metadata.getRaw()` - book.metadata = { - title: metadata?.get('dc:title') ?? info?.Title, - author: metadata?.get('dc:creator') ?? info?.Author, - contributor: metadata?.get('dc:contributor'), - description: metadata?.get('dc:description') ?? info?.Subject, - language: metadata?.get('dc:language'), - publisher: metadata?.get('dc:publisher'), - subject: metadata?.get('dc:subject'), - identifier: metadata?.get('dc:identifier'), - source: metadata?.get('dc:source'), - rights: metadata?.get('dc:rights'), - } + const { metadata, info } = (await pdf.getMetadata()) ?? {}; + // TODO: for better results, parse `metadata.getRaw()` + book.metadata = { + title: metadata?.get("dc:title") ?? info?.Title, + author: metadata?.get("dc:creator") ?? info?.Author, + contributor: metadata?.get("dc:contributor"), + description: metadata?.get("dc:description") ?? info?.Subject, + language: metadata?.get("dc:language"), + publisher: metadata?.get("dc:publisher"), + subject: metadata?.get("dc:subject"), + identifier: metadata?.get("dc:identifier"), + source: metadata?.get("dc:source"), + rights: metadata?.get("dc:rights"), + }; - const outline = await pdf.getOutline() - book.toc = outline?.map(makeTOCItem) + const outline = await pdf.getOutline(); + book.toc = outline?.map(makeTOCItem); - const cache = new Map() - book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({ - id: i, - load: async () => { - const cached = cache.get(i) - if (cached) return cached - const url = await renderPage(await pdf.getPage(i + 1)) - cache.set(i, url) - return url - }, - size: 1000, - })) - book.isExternal = uri => /^\w+:/i.test(uri) - book.resolveHref = async href => { - const parsed = JSON.parse(href) - const dest = typeof parsed === 'string' - ? await pdf.getDestination(parsed) : parsed - const index = await pdf.getPageIndex(dest[0]) - return { index } - } - book.splitTOCHref = async href => { - const parsed = JSON.parse(href) - const dest = typeof parsed === 'string' - ? await pdf.getDestination(parsed) : parsed - const index = await pdf.getPageIndex(dest[0]) - return [index, null] - } - book.getTOCFragment = doc => doc.documentElement - book.getCover = async () => renderPage(await pdf.getPage(1), true) - book.destroy = () => pdf.destroy() - return book -} + const cache = new Map(); + book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({ + id: i, + load: async () => { + const cached = cache.get(i); + if (cached) return cached; + const url = await renderPage(await pdf.getPage(i + 1)); + cache.set(i, url); + return url; + }, + size: 1000, + })); + book.isExternal = (uri) => /^\w+:/i.test(uri); + book.resolveHref = async (href) => { + const parsed = JSON.parse(href); + const dest = + typeof parsed === "string" ? await pdf.getDestination(parsed) : parsed; + const index = await pdf.getPageIndex(dest[0]); + return { index }; + }; + book.splitTOCHref = async (href) => { + const parsed = JSON.parse(href); + const dest = + typeof parsed === "string" ? await pdf.getDestination(parsed) : parsed; + const index = await pdf.getPageIndex(dest[0]); + return [index, null]; + }; + book.getTOCFragment = (doc) => doc.documentElement; + book.getCover = async () => renderPage(await pdf.getPage(1), true); + book.destroy = () => pdf.destroy(); + return book; +}; diff --git a/view.js b/view.js index 7397ea2..d37f791 100644 --- a/view.js +++ b/view.js @@ -76,7 +76,7 @@ const fetchFile = async url => { return new File([await res.blob()], new URL(res.url).pathname) } -export const makeBook = async file => { +export const makeBook = async (file, options = {}) => { if (typeof file === 'string') file = await fetchFile(file) let book if (file.isDirectory) { @@ -105,7 +105,7 @@ export const makeBook = async file => { } else if (await isPDF(file)) { const { makePDF } = await import('./pdf.js') - book = await makePDF(file) + book = await makePDF(file, options.pdf) } else { const { isMOBI, MOBI } = await import('./mobi.js') @@ -230,10 +230,10 @@ export class View extends HTMLElement { this.renderer.goTo(resolved) }) } - async open(book) { + async open(book, options = {}) { if (typeof book === 'string' || typeof book.arrayBuffer === 'function' - || book.isDirectory) book = await makeBook(book) + || book.isDirectory) book = await makeBook(book, options) this.book = book this.language = languageInfo(book.metadata?.language) From d4d87a94f8786a4597af38aa8392525eb5c1f25e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 13 Aug 2026 12:46:11 -0400 Subject: [PATCH 04/30] Avoid redundant render when setting spread before first display --- fixed-layout.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fixed-layout.js b/fixed-layout.js index 8ed7452..9c493bb 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -890,9 +890,10 @@ export class FixedLayout extends HTMLElement { #setSpread(value) { this.spread = value; - const currentSection = - this.book?.sections[this.index] ?? this.book?.sections[0]; + const started = this.#index >= 0; + const currentSection = started ? this.book?.sections[this.index] : null; this.#computeSpreads(); + if (!started) return; const resolved = currentSection ? this.getSpreadOf(currentSection) : null; if (resolved) this.goToSpread(resolved.index, resolved.side, "page"); else this.#render(); From 29bc958c26fa4813579074799804dc2669a9e566 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 14 Aug 2026 14:24:41 -0400 Subject: [PATCH 05/30] Add 'text' interaction mode for PDF (selection-only, no smart detection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'select' keeps smart behavior (drag text to select, drag blank to pan); 'text' always defers to the text/annotation layer so drags never pan — for users who want pure selection. Shift still force-pans in both modes; 'pan' remains force-pan. --- fixed-layout.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fixed-layout.js b/fixed-layout.js index 9c493bb..a1a27f7 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -116,7 +116,7 @@ export class FixedLayout extends HTMLElement { break; } case "interaction-mode": { - if (value === "pan" || value === "select") { + if (value === "pan" || value === "select" || value === "text") { this.#interactionMode = value; } break; @@ -496,6 +496,8 @@ export class FixedLayout extends HTMLElement { #handleMouseDown(event) { if (event.button !== 0) return; + if (this.#isPDF && this.#interactionMode === "text" && !event.shiftKey) + return false; if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) { const t = event.realTarget; if ( From e9e61d885bcb972aeb1d3739f3f02c266cca9a75 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 14 Aug 2026 15:54:00 -0400 Subject: [PATCH 06/30] Touch gestures for fixed-layout: pinch-zoom, pan, swipe paging, double-tap - Single finger: pan while zoomed; horizontal swipe pages at fit (direction mapped through next()/prev() so RTL manga reads correctly); drag on PDF text still defers to selection via the existing smart-detection (realTarget). - Two fingers: pinch-zoom around the midpoint (through the shared zoom machinery, so PDF re-render scheduling works) plus midpoint-pan; lifts back to single-finger pan when one lifts. - Double-tap: zoom 2.5x at the point / reset to fit. - Touch events forwarded from page iframes with converted coordinates; preventDefault only when the engine actually consumes the gesture so native selection/taps stay intact. - touch-action: none on :host and in comic/pdf page documents so the browser does not fight the gesture engine inside iframes. --- comic-book.js | 2 +- fixed-layout.js | 264 +++++++++++++++++++++++++++++++++++++++++++++++- pdf.js | 1 + 3 files changed, 263 insertions(+), 4 deletions(-) diff --git a/comic-book.js b/comic-book.js index 2c3faf4..168e049 100644 --- a/comic-book.js +++ b/comic-book.js @@ -5,7 +5,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { if (cache.has(name)) return cache.get(name) const src = URL.createObjectURL(await loadBlob(name)) const page = URL.createObjectURL( - new Blob([``], { type: 'text/html' })) + new Blob([``], { type: 'text/html' })) urls.set(name, [src, page]) cache.set(name, page) return page diff --git a/fixed-layout.js b/fixed-layout.js index a1a27f7..02e5e67 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -57,6 +57,25 @@ export class FixedLayout extends HTMLElement { startTY: 0, }; dragOffset = { x: 0, y: 0 }; + #touchState = { + mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native" + id: null, + realTarget: null, + startX: 0, + startY: 0, + lastX: 0, + lastY: 0, + startTX: 0, + startTY: 0, + startTime: 0, + startDist: 0, + startScale: 1, + lastMidX: 0, + lastMidY: 0, + lastTapTime: 0, + lastTapX: 0, + lastTapY: 0, + }; #magnifier = { enabled: false, size: 150, @@ -75,6 +94,7 @@ export class FixedLayout extends HTMLElement { display: block; overflow: hidden; position: relative; + touch-action: none; }`); this.#wrapper = document.createElement("div"); @@ -88,6 +108,22 @@ export class FixedLayout extends HTMLElement { this.#root.appendChild(this.#wrapper); this.#observer.observe(this); + + // Touch gestures: pinch-zoom, two-finger pan, single-finger pan while + // zoomed, swipe page-turn at fit, double-tap zoom toggle. Page iframes + // forward their touch events here (see #attachEventListenersToIframe). + this.addEventListener("touchstart", this.#onTouchStart.bind(this), { + passive: false, + }); + this.addEventListener("touchmove", this.#onTouchMove.bind(this), { + passive: false, + }); + this.addEventListener("touchend", this.#onTouchEnd.bind(this), { + passive: false, + }); + this.addEventListener("touchcancel", this.#onTouchCancel.bind(this), { + passive: false, + }); } attributeChangedCallback(name, _, value) { @@ -546,6 +582,184 @@ export class FixedLayout extends HTMLElement { } } + // ----- touch gestures ----- + + #atFitScale() { + return this.#zoom == null; + } + + #touchStartsOnSelectable(target) { + if (!this.#isPDF || this.#interactionMode === "pan") return false; + return !!target?.closest?.(".textLayer span, .annotationLayer a"); + } + + #getPinchInfo(touches) { + const [a, b] = touches; + return { + dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), + midX: (a.clientX + b.clientX) / 2, + midY: (a.clientY + b.clientY) / 2, + }; + } + + #onTouchStart(event) { + if (this.hasAttribute("panel-mode")) return; + const st = this.#touchState; + if (event.touches.length === 1) { + const t = event.touches[0]; + st.mode = "pending"; + st.id = t.identifier; + st.realTarget = event.realTarget ?? event.target ?? null; + st.startX = t.clientX; + st.startY = t.clientY; + st.lastX = t.clientX; + st.lastY = t.clientY; + st.startTX = this.#transform.x; + st.startTY = this.#transform.y; + st.startTime = Date.now(); + } else if (event.touches.length >= 2) { + // A second finger always upgrades to pinch (cancels pan/swipe). + const { dist, midX, midY } = this.#getPinchInfo(event.touches); + st.mode = "pinch"; + st.startDist = dist; + st.startScale = this.#transform.scale; + st.lastMidX = midX; + st.lastMidY = midY; + event.preventDefault(); + } + } + + #onTouchMove(event) { + const st = this.#touchState; + if (!st.mode) return; + + if (st.mode === "pinch") { + if (event.touches.length < 2) return; + const { dist, midX, midY } = this.#getPinchInfo(event.touches); + const rect = this.getBoundingClientRect(); + if (dist > 0 && st.startDist > 0) { + const target = dist / st.startDist * st.startScale; + const ratio = target / this.#transform.scale; + this.#zoomByRatio(midX - rect.left, midY - rect.top, ratio); + } + // two-finger pan: follow the midpoint + this.#transform.x += midX - st.lastMidX; + this.#transform.y += midY - st.lastMidY; + this.#applyTransform(); + st.lastMidX = midX; + st.lastMidY = midY; + event.preventDefault(); + return; + } + + const t = [...event.touches].find((x) => x.identifier === st.id); + if (!t) return; + + if (st.mode === "pending") { + if (Math.hypot(t.clientX - st.startX, t.clientY - st.startY) < 10) + return; + // Gesture decided on first significant movement. + if (this.#touchStartsOnSelectable(st.realTarget)) { + st.mode = "native"; // let the text layer handle selection + return; + } + if (this.#atFitScale()) { + st.mode = "swipe"; // page-turn gesture + } else { + st.mode = "pan"; + this.style.cursor = "grabbing"; + } + } + + if (st.mode === "pan") { + this.#transform.x = st.startTX + (t.clientX - st.startX); + this.#transform.y = st.startTY + (t.clientY - st.startY); + this.#applyTransform(); + event.preventDefault(); + } else if (st.mode === "swipe") { + event.preventDefault(); + } + } + + #onTouchEnd(event) { + const st = this.#touchState; + if (!st.mode) return; + + if (st.mode === "pinch") { + if (event.touches.length === 1) { + // continue as a single-finger pan with the remaining finger + const t = event.touches[0]; + st.mode = "pan"; + st.id = t.identifier; + st.startX = t.clientX; + st.startY = t.clientY; + st.startTX = this.#transform.x; + st.startTY = this.#transform.y; + } else if (event.touches.length === 0) { + st.mode = null; + } + return; + } + + if (st.mode === "pan") { + if (event.touches.length === 0) { + st.mode = null; + this.style.cursor = ""; + } + return; + } + + if (st.mode === "swipe") { + const t = [...event.changedTouches].find( + (x) => x.identifier === st.id, + ); + const dx = (t?.clientX ?? st.startX) - st.startX; + const dy = (t?.clientY ?? st.startY) - st.startY; + st.mode = null; + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) { + // Finger direction maps through next()/prev() so RTL (manga) + // reads correctly: forward is a left swipe in LTR, right in RTL. + if ((dx < 0) !== this.rtl) this.next(); + else this.prev(); + event.preventDefault(); + } + return; + } + + if (st.mode === "pending") { + // no significant movement: a tap. Double-tap toggles zoom. + const t = [...event.changedTouches].find( + (x) => x.identifier === st.id, + ); + const x = t?.clientX ?? st.startX; + const y = t?.clientY ?? st.startY; + const now = Date.now(); + const rect = this.getBoundingClientRect(); + st.mode = null; + if ( + now - st.lastTapTime < 300 && + Math.hypot(x - st.lastTapX, y - st.lastTapY) < 30 + ) { + st.lastTapTime = 0; + if (this.#atFitScale()) { + this.#zoomByRatio(x - rect.left, y - rect.top, 2.5); + } else { + this.resetZoom(); + } + event.preventDefault(); + } else { + st.lastTapTime = now; + st.lastTapX = x; + st.lastTapY = y; + } + } + } + + #onTouchCancel() { + this.#touchState.mode = null; + this.style.cursor = ""; + } + async #createFrame({ index, src: srcOption }, frameId) { const srcOptionIsString = typeof srcOption === "string"; const src = srcOptionIsString ? srcOption : srcOption?.src; @@ -592,17 +806,18 @@ export class FixedLayout extends HTMLElement { } #attachEventListenersToIframe(doc, frameId, frame) { - const convertCoords = (e) => { + const convertPoint = (x, y) => { const iframeRect = frame.iframe.getBoundingClientRect(); const flRect = this.getBoundingClientRect(); const scaleX = iframeRect.width / (doc.documentElement.clientWidth || 1); const scaleY = iframeRect.height / (doc.documentElement.clientHeight || 1); return { - clientX: iframeRect.left - flRect.left + e.clientX * scaleX, - clientY: iframeRect.top - flRect.top + e.clientY * scaleY, + clientX: iframeRect.left - flRect.left + x * scaleX, + clientY: iframeRect.top - flRect.top + y * scaleY, }; }; + const convertCoords = (e) => convertPoint(e.clientX, e.clientY); if (!doc) return; const images = doc.querySelectorAll("img"); @@ -613,6 +828,49 @@ export class FixedLayout extends HTMLElement { img.style.WebkitUserDrag = "none"; }); + // Forward touches to the host gesture engine with converted + // coordinates. Handlers decide whether to preventDefault (pan/pinch/ + // swipe) or let the iframe handle it natively (text selection, taps). + const forwardTouches = (list) => + [...list].map((t) => { + const p = convertPoint(t.clientX, t.clientY); + return { identifier: t.identifier, ...p }; + }); + const synthTouch = (event) => ({ + touches: forwardTouches(event.touches), + changedTouches: forwardTouches(event.changedTouches), + realTarget: event.target, + preventDefault: () => event.preventDefault(), + }); + doc.addEventListener( + "touchstart", + (event) => { + this.#onTouchStart(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchmove", + (event) => { + this.#onTouchMove(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchend", + (event) => { + this.#onTouchEnd(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchcancel", + (event) => { + this.#onTouchCancel(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( "wheel", (event) => { diff --git a/pdf.js b/pdf.js index 6a5cd6f..cef2891 100644 --- a/pdf.js +++ b/pdf.js @@ -100,6 +100,7 @@ const renderPage = async (page, getImageBlob) => { html, body { margin: 0; padding: 0; + touch-action: none; } /* https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a From aba68d87235bd7120b38f9c51206e032b7828004 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 16 Aug 2026 12:44:22 -0400 Subject: [PATCH 07/30] Rect annotations for fixed-layout: fraction-space overlay + hit-testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel annotation pipeline for PDF/comic pages (the CFI overlayer pipeline only exists for the reflowable paginator): - addRectAnnotation({key, index, rects, color}) / removeRectAnnotation(key): rects are page-fraction [x,y,w,h] quads, stored per key and rendered into a full-bleed SVG injected into the page iframe. The SVG uses viewBox 0 0 100 100 with preserveAspectRatio none, so fraction rects stay aligned through iframe CSS-zoom (comics), the host transform-based pan/zoom, and PDF hi-res re-renders (pdf.js rebuilds canvas/textLayer but not body children) — no re-anchoring hooks needed anywhere. - Frames carry their section index; annotations re-render into each freshly created iframe (#showSpread destroys and recreates frames on every spread change). - Clicks are hit-tested against the fraction rects (in iframe coordinate space, denominator = img or documentElement bounding rect so PDF's devicePixelRatio transform cancels) and emit show-rect-annotation with host-space popover coordinates. Skipped while a text selection is active so drag-selecting text doesn't pop the editor. - Comic page img is now display:block so the document box equals the image box (no inline-baseline gap inflating denominators). --- comic-book.js | 2 +- fixed-layout.js | 116 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/comic-book.js b/comic-book.js index 168e049..45416b2 100644 --- a/comic-book.js +++ b/comic-book.js @@ -5,7 +5,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { if (cache.has(name)) return cache.get(name) const src = URL.createObjectURL(await loadBlob(name)) const page = URL.createObjectURL( - new Blob([``], { type: 'text/html' })) + new Blob([``], { type: 'text/html' })) urls.set(name, [src, page]) cache.set(name, page) return page diff --git a/fixed-layout.js b/fixed-layout.js index 02e5e67..1cd5257 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -57,6 +57,10 @@ export class FixedLayout extends HTMLElement { startTY: 0, }; dragOffset = { x: 0, y: 0 }; + // Rect annotations (PDF text highlights; comic regions later). Keyed by + // host-supplied id, stored as page-fraction rects so they survive iframe + // CSS-scaling and PDF hi-res re-renders without any re-anchoring work. + #rectAnnotations = new Map(); #touchState = { mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native" id: null, @@ -760,6 +764,73 @@ export class FixedLayout extends HTMLElement { this.style.cursor = ""; } + // ----- rect annotations ----- + + #frameForIndex(index) { + for (const frame of [this.#left, this.#right, this.#center]) { + if (frame && !frame.blank && frame.index === index) return frame; + } + return null; + } + + // Full-bleed SVG in the page iframe with a 0-100 viewBox and + // preserveAspectRatio:none: fraction-space rects render at any scale, + // so iframe CSS-zoom (comics) and PDF re-renders need no re-anchoring. + #ensureOverlay(doc) { + if (!doc?.body) return null; + let svg = doc.getElementById("foliate-rect-overlay"); + if (!svg) { + const NS = "http://www.w3.org/2000/svg"; + svg = doc.createElementNS(NS, "svg"); + svg.id = "foliate-rect-overlay"; + svg.setAttribute("viewBox", "0 0 100 100"); + svg.setAttribute("preserveAspectRatio", "none"); + svg.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;`; + doc.body.appendChild(svg); + } + return svg; + } + + #renderAnnotationsInto(doc, index) { + const svg = this.#ensureOverlay(doc); + if (!svg) return; + const NS = "http://www.w3.org/2000/svg"; + svg.replaceChildren(); + for (const a of this.#rectAnnotations.values()) { + if (a.index !== index) continue; + for (const [x, y, w, h] of a.rects) { + const rect = doc.createElementNS(NS, "rect"); + rect.setAttribute("x", String(x * 100)); + rect.setAttribute("y", String(y * 100)); + rect.setAttribute("width", String(w * 100)); + rect.setAttribute("height", String(h * 100)); + rect.setAttribute("fill", a.color || "#ffd54f"); + rect.setAttribute("fill-opacity", "0.35"); + svg.appendChild(rect); + } + } + } + + #renderAnnotationsForIndex(index) { + const frame = this.#frameForIndex(index); + const doc = frame?.iframe?.contentDocument; + if (doc) this.#renderAnnotationsInto(doc, index); + } + + addRectAnnotation({ key, index, rects, color }) { + if (!key || index == null || !Array.isArray(rects) || !rects.length) + return; + this.#rectAnnotations.set(key, { index, rects, color }); + this.#renderAnnotationsForIndex(index); + } + + removeRectAnnotation(key) { + const a = this.#rectAnnotations.get(key); + if (!a) return; + this.#rectAnnotations.delete(key); + this.#renderAnnotationsForIndex(a.index); + } + async #createFrame({ index, src: srcOption }, frameId) { const srcOptionIsString = typeof srcOption === "string"; const src = srcOptionIsString ? srcOption : srcOption?.src; @@ -777,7 +848,8 @@ export class FixedLayout extends HTMLElement { iframe.setAttribute("scrolling", "no"); iframe.setAttribute("part", "filter"); this.#wrapper.append(element); - if (!src) return { blank: true, element, iframe, frameId }; + if (!src) + return { blank: true, element, iframe, frameId, index, onZoom }; return new Promise((resolve) => { iframe.addEventListener( "load", @@ -788,7 +860,14 @@ export class FixedLayout extends HTMLElement { ); const { width, height } = getViewport(doc, this.defaultViewport); - this.#attachEventListenersToIframe(doc, frameId, { element, iframe }); + this.#attachEventListenersToIframe(doc, frameId, { + element, + iframe, + index, + }); + // Re-render persisted annotations into the fresh document + // (frames are recreated on every spread change). + this.#renderAnnotationsInto(doc, index); resolve({ element, @@ -797,6 +876,7 @@ export class FixedLayout extends HTMLElement { height: parseFloat(height), onZoom, frameId, + index, }); }, { once: true }, @@ -871,6 +951,38 @@ export class FixedLayout extends HTMLElement { { passive: false }, ); + // Click hit-testing for rect annotations (edit popover). Skipped while a + // text selection is active so drag-selecting doesn't pop the editor. + doc.addEventListener( + "click", + (event) => { + if (!this.#rectAnnotations.size || frame.index == null) return; + const sel = doc.getSelection(); + if (sel && !sel.isCollapsed) return; + const denom = + doc.querySelector("img") || doc.documentElement; + const dr = denom.getBoundingClientRect(); + if (!dr.width || !dr.height) return; + const fx = (event.clientX - dr.left) / dr.width; + const fy = (event.clientY - dr.top) / dr.height; + for (const [key, a] of this.#rectAnnotations) { + if (a.index !== frame.index) continue; + for (const [x, y, w, h] of a.rects) { + if (fx >= x && fx <= x + w && fy >= y && fy <= y + h) { + const { clientX, clientY } = convertCoords(event); + this.dispatchEvent( + new CustomEvent("show-rect-annotation", { + detail: { key, index: a.index, clientX, clientY }, + }), + ); + return; + } + } + } + }, + false, + ); + doc.addEventListener( "wheel", (event) => { From 1c0ebf331fa976babdde32204ad1ec34ea8061d3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 17 Aug 2026 07:45:52 -0400 Subject: [PATCH 08/30] Fix rect annotations: host-side overlay instead of in-iframe SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-iframe SVG overlay was invisible on PDFs for two reasons: - pdf.js applies transform: scale(1/devicePixelRatio) to the iframe's ; an overlay inside that document shrinks into the top-left corner on any dpr != 1 display. The transform is also applied only after the page render, so load-time-injected overlays were sized before the scale factor existed. - Fraction rect geometry itself was correct (selection rects and the denominator are both post-transform), but nothing inside the iframe can escape its html transform. Render annotations host-side instead: a div inside the frame's wrapper element with percentage-positioned children. The wrapper box always equals the visible page area for both formats — comics (iframe CSS-scaled inside it) and PDFs (re-rendered at true scale) — so the overlay is immune to the html transform, zoom re-renders, iframe scaling, and host pan/zoom, still with zero re-anchoring. The in-iframe click hit-test is unchanged (it compares fractions against the same post-transform denominator). --- fixed-layout.js | 78 +++++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/fixed-layout.js b/fixed-layout.js index 1cd5257..29113a9 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -773,48 +773,55 @@ export class FixedLayout extends HTMLElement { return null; } - // Full-bleed SVG in the page iframe with a 0-100 viewBox and - // preserveAspectRatio:none: fraction-space rects render at any scale, - // so iframe CSS-zoom (comics) and PDF re-renders need no re-anchoring. - #ensureOverlay(doc) { - if (!doc?.body) return null; - let svg = doc.getElementById("foliate-rect-overlay"); - if (!svg) { - const NS = "http://www.w3.org/2000/svg"; - svg = doc.createElementNS(NS, "svg"); - svg.id = "foliate-rect-overlay"; - svg.setAttribute("viewBox", "0 0 100 100"); - svg.setAttribute("preserveAspectRatio", "none"); - svg.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;`; - doc.body.appendChild(svg); + // Host-side overlay: a div inside the frame's wrapper element, children + // positioned in percentages of the element box (which always equals the + // visible page area). This is immune to iframe-internal transforms + // (pdf.js scales by 1/devicePixelRatio, which would shrink an + // in-document overlay), comic iframe CSS-scaling, PDF hi-res re-renders, + // and the host transform-based pan/zoom — no re-anchoring anywhere. + #ensureOverlay(frame) { + if (!frame?.element) return null; + let overlay = frame.element.querySelector( + ":scope > .foliate-rect-overlay", + ); + if (!overlay) { + overlay = frame.element.ownerDocument.createElement("div"); + overlay.className = "foliate-rect-overlay"; + overlay.style.cssText = + "position:absolute;inset:0;pointer-events:none;"; + frame.element.style.position = "relative"; + frame.element.appendChild(overlay); } - return svg; + return overlay; } - #renderAnnotationsInto(doc, index) { - const svg = this.#ensureOverlay(doc); - if (!svg) return; - const NS = "http://www.w3.org/2000/svg"; - svg.replaceChildren(); + #renderAnnotationsIntoFrame(frame) { + if (!frame || frame.blank || frame.index == null) return; + const overlay = this.#ensureOverlay(frame); + if (!overlay) return; + const doc = frame.element.ownerDocument; + overlay.replaceChildren(); for (const a of this.#rectAnnotations.values()) { - if (a.index !== index) continue; + if (a.index !== frame.index) continue; for (const [x, y, w, h] of a.rects) { - const rect = doc.createElementNS(NS, "rect"); - rect.setAttribute("x", String(x * 100)); - rect.setAttribute("y", String(y * 100)); - rect.setAttribute("width", String(w * 100)); - rect.setAttribute("height", String(h * 100)); - rect.setAttribute("fill", a.color || "#ffd54f"); - rect.setAttribute("fill-opacity", "0.35"); - svg.appendChild(rect); + const rect = doc.createElement("div"); + Object.assign(rect.style, { + position: "absolute", + left: `${x * 100}%`, + top: `${y * 100}%`, + width: `${w * 100}%`, + height: `${h * 100}%`, + backgroundColor: a.color || "#ffd54f", + opacity: "0.35", + borderRadius: "2px", + }); + overlay.appendChild(rect); } } } #renderAnnotationsForIndex(index) { - const frame = this.#frameForIndex(index); - const doc = frame?.iframe?.contentDocument; - if (doc) this.#renderAnnotationsInto(doc, index); + this.#renderAnnotationsIntoFrame(this.#frameForIndex(index)); } addRectAnnotation({ key, index, rects, color }) { @@ -865,9 +872,10 @@ export class FixedLayout extends HTMLElement { iframe, index, }); - // Re-render persisted annotations into the fresh document - // (frames are recreated on every spread change). - this.#renderAnnotationsInto(doc, index); + // Re-render persisted annotations into the fresh frame (frames + // are recreated on every spread change; the spread fields aren't + // assigned yet, so pass the frame under construction directly). + this.#renderAnnotationsIntoFrame({ element, iframe, index }); resolve({ element, From d0654951b50a042762dca4755597e4de8e8946d3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 17 Aug 2026 08:02:01 -0400 Subject: [PATCH 09/30] Expose the PDF.js document proxy on the book object book.pdf lets the host drive pdf.js directly (page.getTextContent for in-book search) without reaching into renderer internals. --- pdf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdf.js b/pdf.js index cef2891..11a1f6c 100644 --- a/pdf.js +++ b/pdf.js @@ -154,7 +154,7 @@ export const makePDF = async (file, options = {}) => { isEvalSupported: false, }).promise; - const book = { rendition: { layout: "pre-paginated" } }; + const book = { rendition: { layout: "pre-paginated" }, pdf }; const { metadata, info } = (await pdf.getMetadata()) ?? {}; // TODO: for better results, parse `metadata.getRaw()` From 86e234d0060c69b0c5045060b88f947fa7261d0a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 17 Aug 2026 08:32:02 -0400 Subject: [PATCH 10/30] Fix rect hit-test denominator: use the rendered canvas for PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit documentElement's screen rect is devicePixelRatio× smaller than the visible page because pdf.js scales by 1/dpr — using it as the hit-test denominator made click fractions dpr× too large, so clicks landed in the wrong place relative to stored (correct) fractions on any dpr != 1 display. The canvas's post-transform rect is exactly the visible page and shares the textLayer's transform space. --- fixed-layout.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/fixed-layout.js b/fixed-layout.js index 29113a9..767089a 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -961,6 +961,9 @@ export class FixedLayout extends HTMLElement { // Click hit-testing for rect annotations (edit popover). Skipped while a // text selection is active so drag-selecting doesn't pop the editor. + // Denominator: the rendered canvas for PDFs (its post-transform rect IS + // the visible page; documentElement is dpr× too small because pdf.js + // scales by 1/devicePixelRatio), the img for comics. doc.addEventListener( "click", (event) => { @@ -968,9 +971,10 @@ export class FixedLayout extends HTMLElement { const sel = doc.getSelection(); if (sel && !sel.isCollapsed) return; const denom = - doc.querySelector("img") || doc.documentElement; - const dr = denom.getBoundingClientRect(); - if (!dr.width || !dr.height) return; + doc.querySelector("#canvas canvas") || + doc.querySelector("img"); + const dr = denom?.getBoundingClientRect(); + if (!dr || !dr.width || !dr.height) return; const fx = (event.clientX - dr.left) / dr.width; const fy = (event.clientY - dr.top) / dr.height; for (const [key, a] of this.#rectAnnotations) { From 1c812e88134940d5a6bca4a059a92528c8f16ecb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 17 Aug 2026 08:40:03 -0400 Subject: [PATCH 11/30] Add recenter(): reset pan while keeping the current zoom --- fixed-layout.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fixed-layout.js b/fixed-layout.js index 767089a..716941d 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -193,6 +193,25 @@ export class FixedLayout extends HTMLElement { this.#render(); } + // Reset pan while keeping the current zoom: the wrapper's base layout + // already centers the spread, so translate(0,0) recenters it. Re-syncs + // #side afterwards (same rule as drag-end) so next()/prev() stay sane. + recenter() { + this.#transform.x = 0; + this.#transform.y = 0; + this.#applyTransform(); + if ( + !this.#center && + !this.#left?.blank && + !this.#right?.blank && + !this.#portrait + ) { + const leftWidth = (this.#left.width ?? 0) * this.#transform.scale; + const viewportCenterInWrapper = this.getBoundingClientRect().width / 2; + this.#side = viewportCenterInWrapper < leftWidth ? "left" : "right"; + } + } + toggleMagnifier() { this.#magnifier.enabled = !this.#magnifier.enabled; From ea268dffbe8b9f476ba088853d53a5fb491edba1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 08:15:59 -0400 Subject: [PATCH 12/30] Add webtoon (vertical-scroll) renderer for comics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New custom element: every page stacked in one scrolling column (max-width 900px centered on wide screens), the standard long-strip manga reading mode. - Pages lazy-load through an IntersectionObserver with a 150% root margin so they're ready before scrolling into view; placeholders carry an estimated min-height until the image loads, then the real aspect-ratio — set on the placeholder element — so geometry stays stable across unloads and the scrollbar never jumps. - Pages more than 12 positions from the reading point are unloaded (image removed, section blob URL revoked) to bound memory on long webtoons; they simply reload if the user scrolls back. - Relocate events ({reason:'scroll', index, fraction, size:1}) fire on rAF-throttled scroll with the viewport-center page as index and the visible position within it as fraction — compatible with view.js's SectionProgress enrichment, so progress saving/restoring, the slider, and the back-stack all work unchanged. - Renderer contract mirrors fixed-layout: goTo(await {index}) scrolls the page into view; next/prev page by ~one screen (view.init()'s fresh-start probe is a no-op that reports location); goLeft/goRight map to prev/next (vertical flow is direction-independent). No zoom/magnifier APIs — the host already gates those on the flow. - Page images honor --fx-filter from the host for the new brightness/contrast/invert controls. view.js picks the renderer when book.format === 'comic' (now tagged by makeComicBook) and options.comic.flow === 'webtoon'. --- comic-book.js | 1 + view.js | 6 +- webtoon.js | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 webtoon.js diff --git a/comic-book.js b/comic-book.js index 45416b2..ce911b2 100644 --- a/comic-book.js +++ b/comic-book.js @@ -34,6 +34,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { })) book.toc = files.map(name => ({ label: name, href: name })) book.rendition = { layout: 'pre-paginated' } + book.format = 'comic' book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) book.splitTOCHref = href => [href, null] book.getTOCFragment = doc => doc.documentElement diff --git a/view.js b/view.js index d37f791..e6ffca5 100644 --- a/view.js +++ b/view.js @@ -251,7 +251,11 @@ export class View extends HTMLElement { } this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' - if (this.isFixedLayout) { + if (this.book.format === 'comic' && options?.comic?.flow === 'webtoon') { + await import('./webtoon.js') + this.renderer = document.createElement('foliate-webtoon') + } + else if (this.isFixedLayout) { await import('./fixed-layout.js') this.renderer = document.createElement('foliate-fxl') } else { diff --git a/webtoon.js b/webtoon.js new file mode 100644 index 0000000..f597e6d --- /dev/null +++ b/webtoon.js @@ -0,0 +1,224 @@ +// Vertical-scroll (webtoon) renderer for comics: all pages stacked in one +// scrolling column, lazily loaded with an IntersectionObserver and unloaded +// when far from the reading position (keeping placeholder geometry stable +// via aspect-ratio so the scrollbar never jumps). + +export class Webtoon extends HTMLElement { + #root = this.attachShadow({ mode: "closed" }); + #book; + #pages = []; + #index = -1; + #firstNav = true; + #loadIO = null; + #scrollRaf = null; + + constructor() { + super(); + + const sheet = new CSSStyleSheet(); + this.#root.adoptedStyleSheets = [sheet]; + sheet.replaceSync(` + :host { + display: block; + width: 100%; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + } + .page { + max-width: 900px; + margin: 0 auto; + position: relative; + } + .page img { + display: block; + width: 100%; + /* brightness/contrast/invert driven by the host page + (CSS custom properties inherit into shadow DOM) */ + filter: var(--fx-filter, none); + } + `); + + this.addEventListener("scroll", () => this.#onScroll(), { + passive: true, + }); + } + + open(book) { + this.#book = book; + const sections = book.sections ?? []; + // Placeholder height until the image loads and sets the real + // aspect-ratio; oversized so early scrolling doesn't skip pages. + const minH = Math.round((window.innerHeight || 1000) * 1.4); + const frag = document.createDocumentFragment(); + this.#pages = sections.map((section, i) => { + const el = document.createElement("div"); + el.className = "page"; + el.dataset.index = i; + el.style.minHeight = `${minH}px`; + frag.append(el); + return { el, section, loaded: false, img: null }; + }); + this.#root.append(frag); + + this.#loadIO = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + this.#loadIO.unobserve(entry.target); + this.#loadPage(Number(entry.target.dataset.index)); + } + }, + // generous margin so pages are ready before they scroll into view + { root: this, rootMargin: "150% 0%" }, + ); + for (const p of this.#pages) this.#loadIO.observe(p.el); + } + + async #loadPage(i) { + const p = this.#pages[i]; + if (!p || p.loaded) return; + p.loaded = true; + try { + const url = await p.section.load?.(); + if (!url) return; + const html = await (await fetch(url)).text(); + const m = html.match(/src="(blob:[^"]+)"/); + if (!m) return; + const img = new Image(); + img.decoding = "async"; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = m[1]; + }); + // Fix the geometry so later unloading never shifts scroll height. + p.el.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`; + p.el.style.minHeight = "0"; + p.el.append(img); + p.img = img; + } catch (e) { + p.loaded = false; + console.warn("webtoon: failed to load page", i + 1, e); + } + } + + // Free far-away pages (images + their blob URLs) while keeping the + // aspect-ratio placeholder, so the column height stays stable. + #unloadFar(current) { + for (let i = 0; i < this.#pages.length; i++) { + const p = this.#pages[i]; + if (!p.loaded || !p.img) continue; + if (Math.abs(i - current) > 12) { + p.img.remove(); + p.img = null; + p.loaded = false; + p.section.unload?.(); + this.#loadIO.observe(p.el); + } + } + } + + #onScroll() { + if (this.#scrollRaf) return; + this.#scrollRaf = requestAnimationFrame(() => { + this.#scrollRaf = null; + this.#updateIndex(); + }); + } + + #updateIndex() { + if (!this.#pages.length) return; + const hostRect = this.getBoundingClientRect(); + const center = hostRect.top + hostRect.height / 2; + let idx = this.#index < 0 ? 0 : this.#index; + const n = this.#pages.length; + while (idx < n - 1) { + const r = this.#pages[idx].el.getBoundingClientRect(); + if (center < r.bottom - 1) break; + idx++; + } + while (idx > 0) { + const r = this.#pages[idx].el.getBoundingClientRect(); + if (center > r.top + 1) break; + idx--; + } + const r = this.#pages[idx].el.getBoundingClientRect(); + const fraction = Math.min( + 1, + Math.max(0, (center - r.top) / (r.height || 1)), + ); + if (idx !== this.#index) { + this.#index = idx; + this.#unloadFar(idx); + } + this.#report(fraction); + } + + #report(fraction = 0, reason = "scroll") { + this.dispatchEvent( + new CustomEvent("relocate", { + detail: { + reason, + range: null, + index: Math.max(0, this.#index), + fraction, + size: 1, + }, + }), + ); + } + + get index() { + return Math.max(0, this.#index); + } + + // Renderer interface (mirrors fixed-layout): goTo receives a thenable + // resolving to { index }. + async goTo(target) { + const resolved = await target; + const p = this.#pages[resolved?.index]; + if (!p) return; + this.#firstNav = false; + p.el.scrollIntoView({ block: "start" }); + this.#index = resolved.index; + requestAnimationFrame(() => this.#updateIndex()); + } + + // Screen-height paging, like dedicated webtoon readers. The first call is + // view.init()'s fresh-start probe — a no-op that just reports location. + async next() { + if (this.#firstNav) { + this.#firstNav = false; + this.scrollTo(0, 0); + this.#index = 0; + this.#updateIndex(); + return true; + } + this.scrollBy({ top: this.clientHeight * 0.88, behavior: "smooth" }); + return true; + } + + async prev() { + this.scrollBy({ top: -this.clientHeight * 0.88, behavior: "smooth" }); + return true; + } + + goLeft() { + return this.prev(); + } + + goRight() { + return this.next(); + } + + getContents() { + return []; + } + + destroy() { + this.#loadIO?.disconnect(); + } +} + +customElements.define("foliate-webtoon", Webtoon); From e448d36641e41f27f11a24dfdb2b17e2f276d1a9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 08:29:45 -0400 Subject: [PATCH 13/30] Fix webtoon pages never loading: drop IntersectionObserver for scroll-driven loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IO with a shadow-host root never delivered intersections in Chromium (verified live: pages stayed blank while the section.load→fetch→img pipeline itself worked fine when probed from outside). Loading is now driven from the rAF-throttled scroll handler that already tracked the reading position: a window of [index-2, index+4] loads around the viewport-center page, with a 'loading' guard against duplicate in-flight loads. connectedCallback kicks the initial load window. --- webtoon.js | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/webtoon.js b/webtoon.js index f597e6d..7227ac7 100644 --- a/webtoon.js +++ b/webtoon.js @@ -9,7 +9,7 @@ export class Webtoon extends HTMLElement { #pages = []; #index = -1; #firstNav = true; - #loadIO = null; + #connected = false; #scrollRaf = null; constructor() { @@ -57,28 +57,24 @@ export class Webtoon extends HTMLElement { el.dataset.index = i; el.style.minHeight = `${minH}px`; frag.append(el); - return { el, section, loaded: false, img: null }; + return { el, section, loaded: false, img: null, loading: false }; }); this.#root.append(frag); + // Loading is driven from scroll position (#updateIndex), not an + // IntersectionObserver: IO with a shadow-host root proved unreliable + // in Chromium and would leave pages permanently blank. + if (this.#connected) this.#updateIndex(); + } - this.#loadIO = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - this.#loadIO.unobserve(entry.target); - this.#loadPage(Number(entry.target.dataset.index)); - } - }, - // generous margin so pages are ready before they scroll into view - { root: this, rootMargin: "150% 0%" }, - ); - for (const p of this.#pages) this.#loadIO.observe(p.el); + connectedCallback() { + this.#connected = true; + if (this.#pages.length) this.#updateIndex(); } async #loadPage(i) { const p = this.#pages[i]; - if (!p || p.loaded) return; - p.loaded = true; + if (!p || p.loaded || p.loading) return; + p.loading = true; try { const url = await p.section.load?.(); if (!url) return; @@ -97,12 +93,21 @@ export class Webtoon extends HTMLElement { p.el.style.minHeight = "0"; p.el.append(img); p.img = img; + p.loaded = true; } catch (e) { - p.loaded = false; console.warn("webtoon: failed to load page", i + 1, e); + } finally { + p.loading = false; } } + // Load a window around the reading position (a few pages of lookahead). + #loadAround(idx) { + const start = Math.max(0, idx - 2); + const end = Math.min(this.#pages.length - 1, idx + 4); + for (let i = start; i <= end; i++) this.#loadPage(i); + } + // Free far-away pages (images + their blob URLs) while keeping the // aspect-ratio placeholder, so the column height stays stable. #unloadFar(current) { @@ -114,7 +119,6 @@ export class Webtoon extends HTMLElement { p.img = null; p.loaded = false; p.section.unload?.(); - this.#loadIO.observe(p.el); } } } @@ -152,6 +156,7 @@ export class Webtoon extends HTMLElement { this.#index = idx; this.#unloadFar(idx); } + this.#loadAround(idx); this.#report(fraction); } @@ -217,7 +222,6 @@ export class Webtoon extends HTMLElement { } destroy() { - this.#loadIO?.disconnect(); } } From de701036512555b5685d149e15524a224be73eac Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 17:18:47 -0400 Subject: [PATCH 14/30] Fix touch selection paging away mid-drag: gate selection auto-paging to non-touch pointers --- paginator.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 7980c20..07fe8db 100644 --- a/paginator.js +++ b/paginator.js @@ -594,8 +594,11 @@ export class Paginator extends HTMLElement { }, 700) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false - doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerdown', e => { + if (e.pointerType !== 'touch') isPointerSelecting = true + }) doc.addEventListener('pointerup', () => isPointerSelecting = false) + doc.addEventListener('pointercancel', () => isPointerSelecting = false) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) From e1fa4698c6e109dbe379aba6f2328fc8c425e2b4 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:07:36 -0400 Subject: [PATCH 15/30] Clamp touch selection endpoints to the visible page Chrome word-snapping during a touch selection drag can map the endpoint past the visible column into the next (off-screen) page, selecting the entire visible page at once. Re-map the endpoint via caretRangeFromPoint at the same line height inside the visible column whenever it lands outside the last visible range. --- paginator.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/paginator.js b/paginator.js index 07fe8db..69f4b73 100644 --- a/paginator.js +++ b/paginator.js @@ -599,6 +599,10 @@ export class Paginator extends HTMLElement { }) doc.addEventListener('pointerup', () => isPointerSelecting = false) doc.addEventListener('pointercancel', () => isPointerSelecting = false) + let touchSelecting = false + doc.addEventListener('pointerdown', e => { + touchSelecting = e.pointerType === 'touch' + }) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -608,6 +612,11 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return + if (touchSelecting) { + if (sel.isCollapsed) touchSelecting = false + else if (sel.type === 'Range') + this.#clampTouchSelection(sel, doc) + } if (isPointerSelecting && sel.type === 'Range') checkPointerSelection(range, sel) else if (isKeyboardSelecting) { @@ -952,6 +961,25 @@ export class Paginator extends HTMLElement { return getVisibleRange(this.#view.document, this.start - size, this.end - size, this.#getRectMapper()) } + #clampTouchSelection(sel, doc) { + const range = this.#lastVisibleRange + if (!range || !sel.rangeCount) return + const backward = selectionIsBackward(sel) + const probe = sel.getRangeAt(0).cloneRange() + probe.collapse(!backward) + if (backward + ? range.compareBoundaryPoints(Range.START_TO_START, probe) <= 0 + : range.compareBoundaryPoints(Range.END_TO_END, probe) >= 0) return + const rect = probe.getBoundingClientRect() + if (!rect) return + const x = backward ? this.start + 1 : this.end - 1 + const caret = doc.caretRangeFromPoint?.(x, rect.top) + ?? doc.caretPositionFromPoint?.(x, rect.top) + if (!caret) return + const node = caret.startContainer ?? caret.offsetNode + const offset = caret.startOffset ?? caret.offset + if (node) sel.extend(node, offset) + } #afterScroll(reason) { const range = this.#getVisibleRange() this.#lastVisibleRange = range From 8ac36efbda1ef45f0ca4d43b80c7c31f03b1558a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:14:25 -0400 Subject: [PATCH 16/30] Fix clamp coordinates: caretRangeFromPoint needs content-space bounds --- paginator.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 69f4b73..21326fd 100644 --- a/paginator.js +++ b/paginator.js @@ -972,7 +972,10 @@ export class Paginator extends HTMLElement { : range.compareBoundaryPoints(Range.END_TO_END, probe) >= 0) return const rect = probe.getBoundingClientRect() if (!rect) return - const x = backward ? this.start + 1 : this.end - 1 + const size = this.#rtl ? -this.size : this.size + const x = backward + ? this.start - size + 1 + : this.end - size - 1 const caret = doc.caretRangeFromPoint?.(x, rect.top) ?? doc.caretPositionFromPoint?.(x, rect.top) if (!caret) return From 2a54b52fdb8ed98f9ae57a52ab8aa74f8f1ba755 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:18:03 -0400 Subject: [PATCH 17/30] Keep touchSelecting armed through collapsed selectionchange events Chrome emits collapsed selectionchange events while the long-press word selection is being set up; clearing the flag on them disarmed the clamp before the selection ever became a Range. --- paginator.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/paginator.js b/paginator.js index 21326fd..361b1e0 100644 --- a/paginator.js +++ b/paginator.js @@ -603,6 +603,7 @@ export class Paginator extends HTMLElement { doc.addEventListener('pointerdown', e => { touchSelecting = e.pointerType === 'touch' }) + doc.addEventListener('keydown', () => touchSelecting = false) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -612,11 +613,8 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if (touchSelecting) { - if (sel.isCollapsed) touchSelecting = false - else if (sel.type === 'Range') - this.#clampTouchSelection(sel, doc) - } + if (touchSelecting && sel.type === 'Range') + this.#clampTouchSelection(sel, doc) if (isPointerSelecting && sel.type === 'Range') checkPointerSelection(range, sel) else if (isKeyboardSelecting) { From 5102fc70ddf1c5f999ac015d3044d7c88840a0a1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:23:52 -0400 Subject: [PATCH 18/30] Clamp by geometry, not the visible range The last visible range can extend past the visible column on text nodes that span multiple columns (its end-offset bisect uses union rects), so comparing DOM boundary points against it misses off-view selection endpoints. Test the endpoint caret rect against the visible column bounds instead. --- paginator.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/paginator.js b/paginator.js index 361b1e0..fab3d21 100644 --- a/paginator.js +++ b/paginator.js @@ -960,20 +960,18 @@ export class Paginator extends HTMLElement { this.start - size, this.end - size, this.#getRectMapper()) } #clampTouchSelection(sel, doc) { - const range = this.#lastVisibleRange - if (!range || !sel.rangeCount) return + if (!sel.rangeCount) return const backward = selectionIsBackward(sel) const probe = sel.getRangeAt(0).cloneRange() probe.collapse(!backward) - if (backward - ? range.compareBoundaryPoints(Range.START_TO_START, probe) <= 0 - : range.compareBoundaryPoints(Range.END_TO_END, probe) >= 0) return const rect = probe.getBoundingClientRect() - if (!rect) return + if (!rect || (rect.width === 0 && rect.height === 0 && !rect.x && !rect.y)) + return const size = this.#rtl ? -this.size : this.size - const x = backward - ? this.start - size + 1 - : this.end - size - 1 + const left = this.start - size + const right = this.end - size + if (backward ? rect.left >= left : rect.left <= right) return + const x = backward ? left + 1 : right - 1 const caret = doc.caretRangeFromPoint?.(x, rect.top) ?? doc.caretPositionFromPoint?.(x, rect.top) if (!caret) return From 2e392e80d37d480946e6d1d697a2a6e8c3173f47 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:26:47 -0400 Subject: [PATCH 19/30] Retry the touch selection clamp after the gesture ends --- paginator.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index fab3d21..7282663 100644 --- a/paginator.js +++ b/paginator.js @@ -613,8 +613,22 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if (touchSelecting && sel.type === 'Range') + if (touchSelecting && sel.type === 'Range') { this.#clampTouchSelection(sel, doc) + // Chrome ignores (or re-maps) selection writes made from + // JS while the touch selection gesture is active, and no + // pointer or touch event reaches the document when the + // finger lifts after the takeover — the only signal is + // the last selectionchange, mid-gesture. Retry after the + // gesture has most likely ended. + const clampLater = () => { + const s = doc.getSelection() + if (s && s.rangeCount && s.type === 'Range') + this.#clampTouchSelection(s, doc) + } + setTimeout(clampLater, 150) + setTimeout(clampLater, 500) + } if (isPointerSelecting && sel.type === 'Range') checkPointerSelection(range, sel) else if (isKeyboardSelecting) { From cae618ac870e89e462cc794a6f664f28945dab06 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:30:09 -0400 Subject: [PATCH 20/30] Fix selectionIsBackward for iframe selections The helper created its probe range in the module's own document (the host, in the bundled web reader), so boundary points in the content iframe collapsed the range and every selection read as backward. --- paginator.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 7282663..925ffce 100644 --- a/paginator.js +++ b/paginator.js @@ -151,7 +151,11 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - const range = document.createRange() + // the selection may live in an iframe document while this module runs + // in the host document; create the range in the selection's own + // document or the boundary points cannot be compared + const doc = sel.anchorNode?.ownerDocument ?? document + const range = doc.createRange() range.setStart(sel.anchorNode, sel.anchorOffset) range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed From 800fb2fa02439909cc51f22cbca7e7256bc43fb5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 18:32:58 -0400 Subject: [PATCH 21/30] Fix inverted probe collapse in the touch selection clamp --- paginator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 925ffce..08c96f4 100644 --- a/paginator.js +++ b/paginator.js @@ -981,7 +981,7 @@ export class Paginator extends HTMLElement { if (!sel.rangeCount) return const backward = selectionIsBackward(sel) const probe = sel.getRangeAt(0).cloneRange() - probe.collapse(!backward) + probe.collapse(backward) const rect = probe.getBoundingClientRect() if (!rect || (rect.width === 0 && rect.height === 0 && !rect.x && !rect.y)) return From 3fcfc30bd0ef6545e52cddc64a617786026e7faf Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 20:16:10 -0400 Subject: [PATCH 22/30] Keep the long-pressed word covered when Chrome re-anchors a touch drag Dragging just past a line-start word makes Chrome flip the touch selection and re-anchor it at a rendered line break, silently dropping the long-pressed word from the selection. Record the word at the initial long-press and restore its start boundary whenever the selection no longer covers it. --- paginator.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 08c96f4..b97565e 100644 --- a/paginator.js +++ b/paginator.js @@ -608,6 +608,34 @@ export class Paginator extends HTMLElement { touchSelecting = e.pointerType === 'touch' }) doc.addEventListener('keydown', () => touchSelecting = false) + // The word selected by the initial long-press. Chrome can + // re-anchor a touch drag at a rendered line break when the + // finger crosses just past a line-start word, silently dropping + // that word from the selection; keep it covered. + let anchorWordStart = null + doc.addEventListener('pointerdown', e => { + if (e.pointerType === 'touch') anchorWordStart = null + }) + const preserveAnchorWord = sel => { + if (!sel.rangeCount || sel.type !== 'Range') return + const r = sel.getRangeAt(0) + if (!anchorWordStart) { + anchorWordStart = + { node: r.startContainer, offset: r.startOffset } + return + } + const probe = doc.createRange() + probe.setStart(anchorWordStart.node, anchorWordStart.offset) + probe.collapse(true) + if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0) + return + if (selectionIsBackward(sel)) + sel.setBaseAndExtent(sel.anchorNode, sel.anchorOffset, + probe.startContainer, probe.startOffset) + else + sel.setBaseAndExtent(probe.startContainer, probe.startOffset, + sel.focusNode, sel.focusOffset) + } let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -618,6 +646,7 @@ export class Paginator extends HTMLElement { const sel = doc.getSelection() if (!sel.rangeCount) return if (touchSelecting && sel.type === 'Range') { + preserveAnchorWord(sel) this.#clampTouchSelection(sel, doc) // Chrome ignores (or re-maps) selection writes made from // JS while the touch selection gesture is active, and no @@ -627,8 +656,10 @@ export class Paginator extends HTMLElement { // gesture has most likely ended. const clampLater = () => { const s = doc.getSelection() - if (s && s.rangeCount && s.type === 'Range') + if (s && s.rangeCount && s.type === 'Range') { + preserveAnchorWord(s) this.#clampTouchSelection(s, doc) + } } setTimeout(clampLater, 150) setTimeout(clampLater, 500) From 1bd7e52417ab2a0093bf9d949c8f47ca3dc5ba5f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 20:19:26 -0400 Subject: [PATCH 23/30] Repair backward word clipping with extend, not setBaseAndExtent Rewriting both selection boundaries mid-gesture detaches Chrome's touch selection controller and the selection stops following the finger. extend() moves only the focus, which the controller tolerates. --- paginator.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/paginator.js b/paginator.js index b97565e..dd82e8a 100644 --- a/paginator.js +++ b/paginator.js @@ -630,8 +630,7 @@ export class Paginator extends HTMLElement { if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0) return if (selectionIsBackward(sel)) - sel.setBaseAndExtent(sel.anchorNode, sel.anchorOffset, - probe.startContainer, probe.startOffset) + sel.extend(probe.startContainer, probe.startOffset) else sel.setBaseAndExtent(probe.startContainer, probe.startOffset, sel.focusNode, sel.focusOffset) From 4cae16013e5509b888add2396019d3b48eeb5228 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 20:25:40 -0400 Subject: [PATCH 24/30] Repair the anchor word only from the stability-gated deferred path A selection write at the moment Chrome re-anchors the drag detaches its touch selection controller; the same writes on a settled selection are fine. Record the word synchronously, repair only when the selection has been unchanged for 150ms. --- paginator.js | 52 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/paginator.js b/paginator.js index dd82e8a..238b749 100644 --- a/paginator.js +++ b/paginator.js @@ -611,29 +611,38 @@ export class Paginator extends HTMLElement { // The word selected by the initial long-press. Chrome can // re-anchor a touch drag at a rendered line break when the // finger crosses just past a line-start word, silently dropping - // that word from the selection; keep it covered. + // that word from the selection; keep it covered. The repair + // must never run while the gesture is still changing the + // selection: a write at the re-anchor moment detaches Chrome's + // touch selection controller and the selection stops following + // the finger. Focus-only writes on a settled selection are + // tolerated, so record here and repair only from the deferred, + // stability-gated callbacks below. let anchorWordStart = null doc.addEventListener('pointerdown', e => { if (e.pointerType === 'touch') anchorWordStart = null }) - const preserveAnchorWord = sel => { - if (!sel.rangeCount || sel.type !== 'Range') return + const recordAnchorWord = sel => { + if (anchorWordStart || !sel.rangeCount) return + const r = sel.getRangeAt(0) + anchorWordStart = + { node: r.startContainer, offset: r.startOffset } + } + const repairAnchorWord = sel => { + if (!anchorWordStart || !sel.rangeCount + || sel.type !== 'Range') return false const r = sel.getRangeAt(0) - if (!anchorWordStart) { - anchorWordStart = - { node: r.startContainer, offset: r.startOffset } - return - } const probe = doc.createRange() probe.setStart(anchorWordStart.node, anchorWordStart.offset) probe.collapse(true) if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0) - return + return false if (selectionIsBackward(sel)) sel.extend(probe.startContainer, probe.startOffset) else - sel.setBaseAndExtent(probe.startContainer, probe.startOffset, - sel.focusNode, sel.focusOffset) + sel.setBaseAndExtent(probe.startContainer, + probe.startOffset, sel.focusNode, sel.focusOffset) + return true } let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) @@ -645,20 +654,29 @@ export class Paginator extends HTMLElement { const sel = doc.getSelection() if (!sel.rangeCount) return if (touchSelecting && sel.type === 'Range') { - preserveAnchorWord(sel) + recordAnchorWord(sel) this.#clampTouchSelection(sel, doc) // Chrome ignores (or re-maps) selection writes made from // JS while the touch selection gesture is active, and no // pointer or touch event reaches the document when the // finger lifts after the takeover — the only signal is // the last selectionchange, mid-gesture. Retry after the - // gesture has most likely ended. + // gesture has most likely ended, and only while the + // selection is unchanged since the event that scheduled + // the retry (an actively changing selection supersedes it + // and owns the next retry). + const snap = [sel.anchorNode, sel.anchorOffset, + sel.focusNode, sel.focusOffset] + const same = s => s.anchorNode === snap[0] + && s.anchorOffset === snap[1] + && s.focusNode === snap[2] + && s.focusOffset === snap[3] const clampLater = () => { const s = doc.getSelection() - if (s && s.rangeCount && s.type === 'Range') { - preserveAnchorWord(s) - this.#clampTouchSelection(s, doc) - } + if (!s || !s.rangeCount || s.type !== 'Range' + || !same(s)) return + if (repairAnchorWord(s)) return + this.#clampTouchSelection(s, doc) } setTimeout(clampLater, 150) setTimeout(clampLater, 500) From 28f1e78855b45f4b1578f5e1a3e012216c7cdc07 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 14 Sep 2026 08:08:48 -0400 Subject: [PATCH 25/30] Replace native touch selection with a custom gesture Chrome's native touch selection misbehaves in the paginated column layout: quick taps select words, and dragging from a line-start word re-anchors at a rendered line break, dropping the word and freezing the gesture. preventDefault on touchstart disables it entirely; a long-press timer selects the word under the finger instead and drag moves extend word by word, clamped to the visible page and identical for every word. Clean taps clear the selection or follow links (click events being suppressed by the preventDefault). --- paginator.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 238b749..6c81396 100644 --- a/paginator.js +++ b/paginator.js @@ -160,6 +160,19 @@ const selectionIsBackward = sel => { range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed } +const wordAt = (doc, node, offset) => { + if (!node) return null + if (node.nodeType !== 3) return { node, offset } + const text = node.nodeValue + if (!text) return null + const isWord = c => /[\p{L}\p{N}]/u.test(c) + let start = Math.min(offset, text.length) + let end = start + while (start > 0 && isWord(text[start - 1])) start-- + while (end < text.length && isWord(text[end])) end++ + if (start === end) return { node, offset } + return { node, offset: start, endOffset: end } +} const setSelectionTo = (target, collapse) => { let range @@ -452,6 +465,7 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled + #touchSelActive #lastVisibleRange constructor() { super() @@ -693,6 +707,97 @@ export class Paginator extends HTMLElement { doc.addEventListener('focusin', e => this.scrolled ? null : // NOTE: `requestAnimationFrame` is needed in WebKit requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + // ----- custom touch selection ----- + // Chrome's native touch selection is disabled for the paginated + // layout (preventDefault on touchstart): quick taps would select + // words, and dragging from a line-start word re-anchors at a + // rendered line break, dropping the word and freezing the + // gesture. Selection is built here instead: long-press selects + // the word under the finger, dragging extends it word by word, + // clamped to the visible page — identical for every word. + if (!this.scrolled) { + let gesture = null + const clearGesture = () => { + if (gesture?.timer) clearTimeout(gesture.timer) + gesture = null + this.#touchSelActive = false + } + doc.addEventListener('touchstart', e => { + const touch = e.changedTouches[0] + if (!touch || e.touches.length !== 1) return clearGesture() + e.preventDefault() + const g = gesture = { x: touch.clientX, y: touch.clientY, + id: touch.identifier, start: null, end: null, timer: null } + g.timer = setTimeout(() => { + if (gesture !== g) return + const caret = this.#caretAt(g.x, g.y, doc) + const word = caret && wordAt(doc, caret.node, caret.offset) + if (!word || word.endOffset == null) { + gesture = null + return + } + const sel = doc.getSelection() + sel.setBaseAndExtent(word.node, word.offset, + word.node, word.endOffset) + g.start = { node: word.node, offset: word.offset } + g.end = { node: word.node, offset: word.endOffset } + this.#touchSelActive = true + }, 450) + }, { passive: false }) + doc.addEventListener('touchmove', e => { + const g = gesture + if (!g) return + const touch = [...e.changedTouches] + .find(t => t.identifier === g.id) + if (!touch) return + if (!g.start) { + // moving before the long-press engages: this is a + // pan, stand down and let the paginator handle it + if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12) + clearGesture() + return + } + e.preventDefault() + const caret = this.#caretAt(touch.clientX, touch.clientY, doc) + if (!caret) return + const word = wordAt(doc, caret.node, caret.offset) + const end = word?.endOffset != null + ? { node: word.node, offset: word.endOffset } + : caret + const startProbe = doc.createRange() + startProbe.setStart(g.start.node, g.start.offset) + startProbe.collapse(true) + const endProbe = doc.createRange() + endProbe.setStart(end.node, end.offset) + endProbe.collapse(true) + // whichever boundary of the long-pressed word keeps it + // fully covered serves as the anchor + const anchor = startProbe.compareBoundaryPoints( + Range.START_TO_START, endProbe) > 0 ? g.end : g.start + const sel = doc.getSelection() + sel.setBaseAndExtent(anchor.node, anchor.offset, + end.node, end.offset) + }, { passive: false }) + doc.addEventListener('touchend', e => { + const g = gesture + const wasSelecting = !!g?.start + clearGesture() + if (wasSelecting || e.changedTouches.length !== 1) return + const touch = e.changedTouches[0] + const sel = doc.getSelection() + if (sel && sel.rangeCount) { + // a plain tap clears the selection instead of paging + sel.removeAllRanges() + return + } + // touchstart's preventDefault suppresses click events; + // follow links directly + const link = doc.elementFromPoint(touch.clientX, touch.clientY) + ?.closest('a') + link?.click() + }) + doc.addEventListener('touchcancel', clearGesture) + } }) this.#mediaQueryListener = () => { @@ -909,6 +1014,7 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (this.scrolled || state.pinched) return + if (this.#touchSelActive) return if (e.touches.length > 1) { if (this.#touchScrolled) e.preventDefault() return @@ -928,7 +1034,7 @@ export class Paginator extends HTMLElement { } #onTouchEnd() { this.#touchScrolled = false - if (this.scrolled) return + if (this.scrolled || this.#touchSelActive) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at @@ -1025,6 +1131,17 @@ export class Paginator extends HTMLElement { return getVisibleRange(this.#view.document, this.start - size, this.end - size, this.#getRectMapper()) } + #caretAt(x, y, doc) { + const size = this.#rtl ? -this.size : this.size + const left = this.start - size + const right = this.end - size + const cx = Math.min(Math.max(x, left + 1), right - 1) + const caret = doc.caretRangeFromPoint?.(cx, y) + ?? doc.caretPositionFromPoint?.(cx, y) + if (!caret) return null + return { node: caret.startContainer ?? caret.offsetNode, + offset: caret.startOffset ?? caret.offset } + } #clampTouchSelection(sel, doc) { if (!sel.rangeCount) return const backward = selectionIsBackward(sel) From ea34de5bf57f67ed8349b2472089766a3a326c3b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 14 Sep 2026 08:44:26 -0400 Subject: [PATCH 26/30] Revert experimental touch selection work back to the minimal auto-paging gate The custom gesture system and the selection clamp/repair chain replaced browser behavior with worse approximations. Keep only the non-touch gate on selection auto-paging (de70103), which fixes drag-selecting paging away mid-gesture; everything else returns to upstream behavior. --- paginator.js | 214 +-------------------------------------------------- 1 file changed, 2 insertions(+), 212 deletions(-) diff --git a/paginator.js b/paginator.js index 6c81396..07fe8db 100644 --- a/paginator.js +++ b/paginator.js @@ -151,28 +151,11 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - // the selection may live in an iframe document while this module runs - // in the host document; create the range in the selection's own - // document or the boundary points cannot be compared - const doc = sel.anchorNode?.ownerDocument ?? document - const range = doc.createRange() + const range = document.createRange() range.setStart(sel.anchorNode, sel.anchorOffset) range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed } -const wordAt = (doc, node, offset) => { - if (!node) return null - if (node.nodeType !== 3) return { node, offset } - const text = node.nodeValue - if (!text) return null - const isWord = c => /[\p{L}\p{N}]/u.test(c) - let start = Math.min(offset, text.length) - let end = start - while (start > 0 && isWord(text[start - 1])) start-- - while (end < text.length && isWord(text[end])) end++ - if (start === end) return { node, offset } - return { node, offset: start, endOffset: end } -} const setSelectionTo = (target, collapse) => { let range @@ -465,7 +448,6 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled - #touchSelActive #lastVisibleRange constructor() { super() @@ -617,47 +599,6 @@ export class Paginator extends HTMLElement { }) doc.addEventListener('pointerup', () => isPointerSelecting = false) doc.addEventListener('pointercancel', () => isPointerSelecting = false) - let touchSelecting = false - doc.addEventListener('pointerdown', e => { - touchSelecting = e.pointerType === 'touch' - }) - doc.addEventListener('keydown', () => touchSelecting = false) - // The word selected by the initial long-press. Chrome can - // re-anchor a touch drag at a rendered line break when the - // finger crosses just past a line-start word, silently dropping - // that word from the selection; keep it covered. The repair - // must never run while the gesture is still changing the - // selection: a write at the re-anchor moment detaches Chrome's - // touch selection controller and the selection stops following - // the finger. Focus-only writes on a settled selection are - // tolerated, so record here and repair only from the deferred, - // stability-gated callbacks below. - let anchorWordStart = null - doc.addEventListener('pointerdown', e => { - if (e.pointerType === 'touch') anchorWordStart = null - }) - const recordAnchorWord = sel => { - if (anchorWordStart || !sel.rangeCount) return - const r = sel.getRangeAt(0) - anchorWordStart = - { node: r.startContainer, offset: r.startOffset } - } - const repairAnchorWord = sel => { - if (!anchorWordStart || !sel.rangeCount - || sel.type !== 'Range') return false - const r = sel.getRangeAt(0) - const probe = doc.createRange() - probe.setStart(anchorWordStart.node, anchorWordStart.offset) - probe.collapse(true) - if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0) - return false - if (selectionIsBackward(sel)) - sel.extend(probe.startContainer, probe.startOffset) - else - sel.setBaseAndExtent(probe.startContainer, - probe.startOffset, sel.focusNode, sel.focusOffset) - return true - } let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -667,34 +608,6 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if (touchSelecting && sel.type === 'Range') { - recordAnchorWord(sel) - this.#clampTouchSelection(sel, doc) - // Chrome ignores (or re-maps) selection writes made from - // JS while the touch selection gesture is active, and no - // pointer or touch event reaches the document when the - // finger lifts after the takeover — the only signal is - // the last selectionchange, mid-gesture. Retry after the - // gesture has most likely ended, and only while the - // selection is unchanged since the event that scheduled - // the retry (an actively changing selection supersedes it - // and owns the next retry). - const snap = [sel.anchorNode, sel.anchorOffset, - sel.focusNode, sel.focusOffset] - const same = s => s.anchorNode === snap[0] - && s.anchorOffset === snap[1] - && s.focusNode === snap[2] - && s.focusOffset === snap[3] - const clampLater = () => { - const s = doc.getSelection() - if (!s || !s.rangeCount || s.type !== 'Range' - || !same(s)) return - if (repairAnchorWord(s)) return - this.#clampTouchSelection(s, doc) - } - setTimeout(clampLater, 150) - setTimeout(clampLater, 500) - } if (isPointerSelecting && sel.type === 'Range') checkPointerSelection(range, sel) else if (isKeyboardSelecting) { @@ -707,97 +620,6 @@ export class Paginator extends HTMLElement { doc.addEventListener('focusin', e => this.scrolled ? null : // NOTE: `requestAnimationFrame` is needed in WebKit requestAnimationFrame(() => this.#scrollToAnchor(e.target))) - // ----- custom touch selection ----- - // Chrome's native touch selection is disabled for the paginated - // layout (preventDefault on touchstart): quick taps would select - // words, and dragging from a line-start word re-anchors at a - // rendered line break, dropping the word and freezing the - // gesture. Selection is built here instead: long-press selects - // the word under the finger, dragging extends it word by word, - // clamped to the visible page — identical for every word. - if (!this.scrolled) { - let gesture = null - const clearGesture = () => { - if (gesture?.timer) clearTimeout(gesture.timer) - gesture = null - this.#touchSelActive = false - } - doc.addEventListener('touchstart', e => { - const touch = e.changedTouches[0] - if (!touch || e.touches.length !== 1) return clearGesture() - e.preventDefault() - const g = gesture = { x: touch.clientX, y: touch.clientY, - id: touch.identifier, start: null, end: null, timer: null } - g.timer = setTimeout(() => { - if (gesture !== g) return - const caret = this.#caretAt(g.x, g.y, doc) - const word = caret && wordAt(doc, caret.node, caret.offset) - if (!word || word.endOffset == null) { - gesture = null - return - } - const sel = doc.getSelection() - sel.setBaseAndExtent(word.node, word.offset, - word.node, word.endOffset) - g.start = { node: word.node, offset: word.offset } - g.end = { node: word.node, offset: word.endOffset } - this.#touchSelActive = true - }, 450) - }, { passive: false }) - doc.addEventListener('touchmove', e => { - const g = gesture - if (!g) return - const touch = [...e.changedTouches] - .find(t => t.identifier === g.id) - if (!touch) return - if (!g.start) { - // moving before the long-press engages: this is a - // pan, stand down and let the paginator handle it - if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12) - clearGesture() - return - } - e.preventDefault() - const caret = this.#caretAt(touch.clientX, touch.clientY, doc) - if (!caret) return - const word = wordAt(doc, caret.node, caret.offset) - const end = word?.endOffset != null - ? { node: word.node, offset: word.endOffset } - : caret - const startProbe = doc.createRange() - startProbe.setStart(g.start.node, g.start.offset) - startProbe.collapse(true) - const endProbe = doc.createRange() - endProbe.setStart(end.node, end.offset) - endProbe.collapse(true) - // whichever boundary of the long-pressed word keeps it - // fully covered serves as the anchor - const anchor = startProbe.compareBoundaryPoints( - Range.START_TO_START, endProbe) > 0 ? g.end : g.start - const sel = doc.getSelection() - sel.setBaseAndExtent(anchor.node, anchor.offset, - end.node, end.offset) - }, { passive: false }) - doc.addEventListener('touchend', e => { - const g = gesture - const wasSelecting = !!g?.start - clearGesture() - if (wasSelecting || e.changedTouches.length !== 1) return - const touch = e.changedTouches[0] - const sel = doc.getSelection() - if (sel && sel.rangeCount) { - // a plain tap clears the selection instead of paging - sel.removeAllRanges() - return - } - // touchstart's preventDefault suppresses click events; - // follow links directly - const link = doc.elementFromPoint(touch.clientX, touch.clientY) - ?.closest('a') - link?.click() - }) - doc.addEventListener('touchcancel', clearGesture) - } }) this.#mediaQueryListener = () => { @@ -1014,7 +836,6 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (this.scrolled || state.pinched) return - if (this.#touchSelActive) return if (e.touches.length > 1) { if (this.#touchScrolled) e.preventDefault() return @@ -1034,7 +855,7 @@ export class Paginator extends HTMLElement { } #onTouchEnd() { this.#touchScrolled = false - if (this.scrolled || this.#touchSelActive) return + if (this.scrolled) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at @@ -1131,37 +952,6 @@ export class Paginator extends HTMLElement { return getVisibleRange(this.#view.document, this.start - size, this.end - size, this.#getRectMapper()) } - #caretAt(x, y, doc) { - const size = this.#rtl ? -this.size : this.size - const left = this.start - size - const right = this.end - size - const cx = Math.min(Math.max(x, left + 1), right - 1) - const caret = doc.caretRangeFromPoint?.(cx, y) - ?? doc.caretPositionFromPoint?.(cx, y) - if (!caret) return null - return { node: caret.startContainer ?? caret.offsetNode, - offset: caret.startOffset ?? caret.offset } - } - #clampTouchSelection(sel, doc) { - if (!sel.rangeCount) return - const backward = selectionIsBackward(sel) - const probe = sel.getRangeAt(0).cloneRange() - probe.collapse(backward) - const rect = probe.getBoundingClientRect() - if (!rect || (rect.width === 0 && rect.height === 0 && !rect.x && !rect.y)) - return - const size = this.#rtl ? -this.size : this.size - const left = this.start - size - const right = this.end - size - if (backward ? rect.left >= left : rect.left <= right) return - const x = backward ? left + 1 : right - 1 - const caret = doc.caretRangeFromPoint?.(x, rect.top) - ?? doc.caretPositionFromPoint?.(x, rect.top) - if (!caret) return - const node = caret.startContainer ?? caret.offsetNode - const offset = caret.startOffset ?? caret.offset - if (node) sel.extend(node, offset) - } #afterScroll(reason) { const range = this.#getVisibleRange() this.#lastVisibleRange = range From 9cfe266929ba650438d908253671a75a42dc4ccf Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 14 Sep 2026 19:45:50 -0400 Subject: [PATCH 27/30] Ignore small height-only viewport resizes Mobile browser toolbar transitions change the viewport height by a fraction mid-gesture; re-rendering for them re-wraps the whole book and reads as a page refresh. Re-render only for width changes or height changes of 25% or more. --- paginator.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/paginator.js b/paginator.js index 07fe8db..3127294 100644 --- a/paginator.js +++ b/paginator.js @@ -427,7 +427,23 @@ export class Paginator extends HTMLElement { 'max-inline-size', 'max-block-size', 'max-column-count', ] #root = this.attachShadow({ mode: 'closed' }) - #observer = new ResizeObserver(() => this.render()) + #lastContainerSize + #observer = new ResizeObserver(entries => { + // Mobile browsers grow/shrink the viewport by ~7–17% when the + // toolbar hides/shows mid-gesture; re-wrapping the whole book + // for that reads as a page refresh. Only re-render for real + // layout changes: any width change, or a height change large + // enough to be a rotation, keyboard, or split-screen. + const rect = entries?.[entries.length - 1]?.contentRect + if (rect && rect.width && rect.height && this.#lastContainerSize) { + const [w, h] = this.#lastContainerSize + if (Math.abs(rect.width - w) < 1 + && Math.abs(rect.height - h) / h < 0.25) return + } + if (rect && rect.width && rect.height) + this.#lastContainerSize = [rect.width, rect.height] + this.render() + }) #top #background #container From 914ade8a79579d0b592fd62bf2939366e752be86 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 08:54:56 -0400 Subject: [PATCH 28/30] Custom touch selection: own the gesture, clamp to the page Chromium's native touch selection cannot be used in the columned layout: quick taps select words, the word iterator re-anchors at page boundaries (jumping to other pages), and the browser auto-scrolls the container when selections cross pages. preventDefault on touchstart disables it; long-press selects the word under the finger, drag extends word by word clamped to the visible page, and pan/snap stand down while a selection gesture is active. Native rendering still paints the selection. --- paginator.js | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/paginator.js b/paginator.js index 3127294..ede314b 100644 --- a/paginator.js +++ b/paginator.js @@ -151,11 +151,28 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - const range = document.createRange() + // the selection may live in an iframe document while this module runs + // in the host document; create the range in the selection's own + // document or the boundary points cannot be compared + const doc = sel.anchorNode?.ownerDocument ?? document + const range = doc.createRange() range.setStart(sel.anchorNode, sel.anchorOffset) range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed } +const wordAt = (doc, node, offset) => { + if (!node) return null + if (node.nodeType !== 3) return { node, offset } + const text = node.nodeValue + if (!text) return null + const isWord = c => /[\p{L}\p{N}]/u.test(c) + let start = Math.min(offset, text.length) + let end = start + while (start > 0 && isWord(text[start - 1])) start-- + while (end < text.length && isWord(text[end])) end++ + if (start === end) return { node, offset } + return { node, offset: start, endOffset: end } +} const setSelectionTo = (target, collapse) => { let range @@ -464,6 +481,7 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled + #touchSelActive #lastVisibleRange constructor() { super() @@ -636,6 +654,93 @@ export class Paginator extends HTMLElement { doc.addEventListener('focusin', e => this.scrolled ? null : // NOTE: `requestAnimationFrame` is needed in WebKit requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + // ----- custom touch selection ----- + // Chromium's native touch selection misbehaves in the + // columned layout: quick taps select words, the word + // iterator re-anchors at page boundaries, and the browser + // auto-scrolls the container when selections cross pages. + // preventDefault on touchstart disables it entirely; + // long-press selects the word under the finger and drag + // extends it word by word, clamped to the visible page, so + // the selection can never leave the page and the view can + // never move mid-gesture. + if (!this.scrolled) { + let gesture = null + const clearGesture = () => { + if (gesture?.timer) clearTimeout(gesture.timer) + gesture = null + this.#touchSelActive = false + } + doc.addEventListener('touchstart', e => { + const touch = e.changedTouches[0] + if (!touch || e.touches.length !== 1) return clearGesture() + e.preventDefault() + const g = gesture = { x: touch.clientX, y: touch.clientY, + id: touch.identifier, start: null, end: null, timer: null } + g.timer = setTimeout(() => { + if (gesture !== g) return + const caret = this.#caretAt(g.x, g.y, doc) + const word = caret && wordAt(doc, caret.node, caret.offset) + if (!word || word.endOffset == null) { + gesture = null + return + } + const sel = doc.getSelection() + sel.setBaseAndExtent(word.node, word.offset, + word.node, word.endOffset) + g.start = { node: word.node, offset: word.offset } + g.end = { node: word.node, offset: word.endOffset } + this.#touchSelActive = true + }, 450) + }, { passive: false }) + doc.addEventListener('touchmove', e => { + const g = gesture + if (!g) return + const touch = [...e.changedTouches] + .find(t => t.identifier === g.id) + if (!touch) return + if (!g.start) { + // moving before the long-press engages: a pan — + // stand down and let the paginator handle it + if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12) + clearGesture() + return + } + e.preventDefault() + const caret = this.#caretAt(touch.clientX, touch.clientY, doc) + if (!caret) return + const word = wordAt(doc, caret.node, caret.offset) + const end = word?.endOffset != null + ? { node: word.node, offset: word.endOffset } + : caret + const startProbe = doc.createRange() + startProbe.setStart(g.start.node, g.start.offset) + startProbe.collapse(true) + const endProbe = doc.createRange() + endProbe.setStart(end.node, end.offset) + endProbe.collapse(true) + // whichever boundary of the long-pressed word keeps + // it fully covered serves as the anchor + const anchor = startProbe.compareBoundaryPoints( + Range.START_TO_START, endProbe) > 0 ? g.end : g.start + doc.getSelection().setBaseAndExtent(anchor.node, anchor.offset, + end.node, end.offset) + }, { passive: false }) + doc.addEventListener('touchend', e => { + const g = gesture + const wasSelecting = !!g?.start + clearGesture() + if (wasSelecting || e.changedTouches.length !== 1) return + const touch = e.changedTouches[0] + // touchstart's preventDefault suppresses click + // events; follow links directly + const link = doc.elementFromPoint(touch.clientX, touch.clientY) + ?.closest('a') + link?.click() + }) + doc.addEventListener('touchcancel', clearGesture) + doc.addEventListener('contextmenu', e => e.preventDefault()) + } }) this.#mediaQueryListener = () => { @@ -839,6 +944,21 @@ export class Paginator extends HTMLElement { }) }) } + #caretAt(x, y, doc) { + // Touch points and caret mapping share the iframe's own + // (content) space, where the visible page is [start - size, + // start] for LTR; clamp into it so selections cannot leave + // the visible page. + const size = this.#rtl ? -this.size : this.size + const left = this.start - size + const right = this.start + const cx = Math.min(Math.max(x, left + 2), right - 2) + const caret = doc.caretRangeFromPoint?.(cx, y) + ?? doc.caretPositionFromPoint?.(cx, y) + if (!caret) return null + return { node: caret.startContainer ?? caret.offsetNode, + offset: caret.startOffset ?? caret.offset } + } #onTouchStart(e) { const touch = e.changedTouches[0] this.#touchState = { @@ -852,6 +972,7 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (this.scrolled || state.pinched) return + if (this.#touchSelActive) return if (e.touches.length > 1) { if (this.#touchScrolled) e.preventDefault() return @@ -871,7 +992,7 @@ export class Paginator extends HTMLElement { } #onTouchEnd() { this.#touchScrolled = false - if (this.scrolled) return + if (this.scrolled || this.#touchSelActive) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at From f872a01d5318e2092c8ec7434d5060139f3fd32a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 12:43:24 -0400 Subject: [PATCH 29/30] Dispatch a synthetic click on quick taps touchstart's preventDefault suppresses the click events that foliate's link handling and the overlayer's highlight hit-testing depend on; dispatch one at the tapped element so internal links and the highlight edit popover work on touch as on desktop. --- paginator.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/paginator.js b/paginator.js index ede314b..9adc0f8 100644 --- a/paginator.js +++ b/paginator.js @@ -733,10 +733,13 @@ export class Paginator extends HTMLElement { if (wasSelecting || e.changedTouches.length !== 1) return const touch = e.changedTouches[0] // touchstart's preventDefault suppresses click - // events; follow links directly - const link = doc.elementFromPoint(touch.clientX, touch.clientY) - ?.closest('a') - link?.click() + // events; dispatch one so foliate's link handling and + // the overlayer's highlight hit-testing (the edit + // popover) behave as on desktop + const el = doc.elementFromPoint(touch.clientX, touch.clientY) + el?.dispatchEvent(new (doc.defaultView ?? window).MouseEvent( + 'click', { clientX: touch.clientX, clientY: touch.clientY, + bubbles: true, cancelable: true })) }) doc.addEventListener('touchcancel', clearGesture) doc.addEventListener('contextmenu', e => e.preventDefault()) From 422e8e0ec0e61e5d0c6d6e2fa1d777f48d8a1175 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 12:56:08 -0400 Subject: [PATCH 30/30] Don't snap on taps that never panned A tap goes through #onTouchEnd's snap, which emits a no-op relocate; readers treat any relocate as a page turn and dismiss popovers, so the highlight edit popover opened by the tap's synthetic click was immediately hidden. --- paginator.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/paginator.js b/paginator.js index 9adc0f8..63364f9 100644 --- a/paginator.js +++ b/paginator.js @@ -994,8 +994,13 @@ export class Paginator extends HTMLElement { this.scrollBy(dx, dy) } #onTouchEnd() { + const wasScrolled = this.#touchScrolled this.#touchScrolled = false if (this.scrolled || this.#touchSelActive) return + // A touch that never panned (a tap) is already page-aligned; + // snapping anyway emits a no-op relocate, which readers treat + // as a page turn (e.g. dismissing a just-opened popover). + if (!wasScrolled) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at