From aba68d87235bd7120b38f9c51206e032b7828004 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 16 Aug 2026 12:44:22 -0400 Subject: [PATCH] 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) => {