Rect annotations for fixed-layout: fraction-space overlay + hit-testing

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).
This commit is contained in:
2026-08-16 12:44:22 -04:00
parent e9e61d885b
commit aba68d8723
2 changed files with 115 additions and 3 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
if (cache.has(name)) return cache.get(name) if (cache.has(name)) return cache.get(name)
const src = URL.createObjectURL(await loadBlob(name)) const src = URL.createObjectURL(await loadBlob(name))
const page = URL.createObjectURL( const page = URL.createObjectURL(
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0; touch-action: none"><img src="${src}" style="image-rendering: high-quality"></body></html>`], { type: 'text/html' })) new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0; touch-action: none"><img src="${src}" style="display: block; image-rendering: high-quality"></body></html>`], { type: 'text/html' }))
urls.set(name, [src, page]) urls.set(name, [src, page])
cache.set(name, page) cache.set(name, page)
return page return page
+114 -2
View File
@@ -57,6 +57,10 @@ export class FixedLayout extends HTMLElement {
startTY: 0, startTY: 0,
}; };
dragOffset = { x: 0, y: 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 = { #touchState = {
mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native" mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native"
id: null, id: null,
@@ -760,6 +764,73 @@ export class FixedLayout extends HTMLElement {
this.style.cursor = ""; 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) { async #createFrame({ index, src: srcOption }, frameId) {
const srcOptionIsString = typeof srcOption === "string"; const srcOptionIsString = typeof srcOption === "string";
const src = srcOptionIsString ? srcOption : srcOption?.src; const src = srcOptionIsString ? srcOption : srcOption?.src;
@@ -777,7 +848,8 @@ export class FixedLayout extends HTMLElement {
iframe.setAttribute("scrolling", "no"); iframe.setAttribute("scrolling", "no");
iframe.setAttribute("part", "filter"); iframe.setAttribute("part", "filter");
this.#wrapper.append(element); 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) => { return new Promise((resolve) => {
iframe.addEventListener( iframe.addEventListener(
"load", "load",
@@ -788,7 +860,14 @@ export class FixedLayout extends HTMLElement {
); );
const { width, height } = getViewport(doc, this.defaultViewport); 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({ resolve({
element, element,
@@ -797,6 +876,7 @@ export class FixedLayout extends HTMLElement {
height: parseFloat(height), height: parseFloat(height),
onZoom, onZoom,
frameId, frameId,
index,
}); });
}, },
{ once: true }, { once: true },
@@ -871,6 +951,38 @@ export class FixedLayout extends HTMLElement {
{ passive: false }, { 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( doc.addEventListener(
"wheel", "wheel",
(event) => { (event) => {