fix(reader): PDF selection popover shows reliably on touch, below selection

Chromium's touch selection takeover swallows pointerup in the fx iframe,
so the PDF popover's only trigger never fired on real phones — it only
appeared when timing happened to deliver the event. Mirror the EPUB
path: a selectionchange debounce (400ms settle) opens the popover, with
the pointerup path kept for desktop plus the quick-tap word-select guard.

Also place the popover BELOW the selection on coarse pointers (+90px
clearing the native selection menu and drag handles), matching the
reflowable path; desktop keeps above-placement.
This commit is contained in:
John O'Keefe
2026-09-17 10:40:12 -04:00
parent 16bbeec5a3
commit 960305dfa9
+106 -58
View File
@@ -724,7 +724,9 @@ document.addEventListener("alpine:init", () => {
// drags, so the popover's pointerup trigger never fires for // drags, so the popover's pointerup trigger never fires for
// them; a selection that has been stable for a moment is the // them; a selection that has been stable for a moment is the
// settled gesture — open the popover for it. // settled gesture — open the popover for it.
if (!this.isFixedLayout) if (this.isFixedLayout && doc.querySelector(".textLayer"))
this.schedulePDFSelectionPopover(doc, index);
else if (!this.isFixedLayout)
this.scheduleSelectionPopover(doc, index); this.scheduleSelectionPopover(doc, index);
}); });
} }
@@ -785,65 +787,23 @@ document.addEventListener("alpine:init", () => {
// spread renders (during view.init), which is after this listener // spread renders (during view.init), which is after this listener
// attaches — the stale copy here would always be falsy. // attaches — the stale copy here would always be falsy.
if (this.isFixedLayout && doc.querySelector(".textLayer")) { if (this.isFixedLayout && doc.querySelector(".textLayer")) {
const checkPDFSelection = () => {
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect
// is dpr× too small and would inflate every fraction (highlight
// shifted right/oversized on any dpr != 1 display). The canvas's
// rect is in the same transform-inclusive space as the textLayer
// span rects, so the dpr scaling cancels exactly.
const denom =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
let dr = denom?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) {
const vw = doc.defaultView;
dr = {
left: 0,
top: 0,
width: vw?.innerWidth || 1,
height: vw?.innerHeight || 1,
} as DOMRect;
}
const rects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) rects.push([x, y, w, h]);
}
if (!rects.length) return;
// Map the first rect to host-space for popover placement.
// The canvas's screen rect maps 1:1 onto the host iframe box
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
// kept general for the comic img fallback.
const frame = doc.defaultView?.frameElement as HTMLElement | null;
if (!frame) return;
const fr = frame.getBoundingClientRect();
const first = range.getBoundingClientRect();
const sx = fr.width / dr.width;
const sy = fr.height / dr.height;
this.pdfSelDoc = doc;
this.openSelectionPopover({
mode: "create",
x: fr.left + first.left * sx + (first.width * sx) / 2,
y: fr.top + first.top * sy,
text,
cfi: "",
pdfPage: index,
pdfRects: rects,
});
};
doc.addEventListener( doc.addEventListener(
"pointerup", "pointerup",
() => setTimeout(checkPDFSelection, 0), (e: PointerEvent) =>
setTimeout(() => {
// Chrome on touch selects the word under a quick tap —
// that is a page-turn tap, not a selection gesture; clear
// it and show nothing (same guard as the reflowable path).
if (
e.pointerType === "touch" &&
this.lastTouchDownT &&
Date.now() - this.lastTouchDownT < 300
) {
doc.getSelection()?.removeAllRanges();
return;
}
this.checkPDFSelectionFor(doc, index);
}, 0),
{ passive: true }, { passive: true },
); );
} }
@@ -1332,6 +1292,94 @@ document.addEventListener("alpine:init", () => {
this.openPopoverForSelection(sel, doc, index); this.openPopoverForSelection(sel, doc, index);
}, 400); }, 400);
}, },
// PDF mirror of scheduleSelectionPopover: the gesture takeover
// swallows pointerup for touch selection drags in the fx iframe too,
// so a selection that has been stable for a moment is the settled
// gesture — open the popover for it.
schedulePDFSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {
this.selPopTimer = null;
// A tap on a painted highlight re-opens the popover in edit mode
// via its own path — never clobber it.
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
return;
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) {
if (
this.selectionPopover.open &&
this.selectionPopover.mode === "create"
)
this.hideSelectionPopover();
return;
}
this.checkPDFSelectionFor(doc, index);
}, 400);
},
checkPDFSelectionFor(doc: any, index: number) {
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect
// is dpr× too small and would inflate every fraction (highlight
// shifted right/oversized on any dpr != 1 display). The canvas's
// rect is in the same transform-inclusive space as the textLayer
// span rects, so the dpr scaling cancels exactly.
const denom =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
let dr = denom?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) {
const vw = doc.defaultView;
dr = {
left: 0,
top: 0,
width: vw?.innerWidth || 1,
height: vw?.innerHeight || 1,
} as DOMRect;
}
const rects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) rects.push([x, y, w, h]);
}
if (!rects.length) return;
// Map the first rect to host-space for popover placement.
// The canvas's screen rect maps 1:1 onto the host iframe box
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
// kept general for the comic img fallback.
const frame = doc.defaultView?.frameElement as HTMLElement | null;
if (!frame) return;
const fr = frame.getBoundingClientRect();
const first = range.getBoundingClientRect();
const sx = fr.width / dr.width;
const sy = fr.height / dr.height;
// On touch the browser's own selection menu attaches ABOVE the
// selection and its drag handles sit just below it — place ours
// below both (same +90 clearance the reflowable path uses). Fine
// pointers (desktop) keep the above-the-selection placement.
const coarse = window.matchMedia("(pointer: coarse)").matches;
const y = coarse
? fr.top + first.bottom * sy + 90
: fr.top + first.top * sy;
this.pdfSelDoc = doc;
this.openSelectionPopover({
mode: "create",
x: fr.left + first.left * sx + (first.width * sx) / 2,
y,
text,
cfi: "",
pdfPage: index,
pdfRects: rects,
});
},
// Trim a touch selection to the visible page. The visible column // Trim a touch selection to the visible page. The visible column
// occupies content x [start - size, start]; endpoints beyond it are // occupies content x [start - size, start]; endpoints beyond it are
// re-mapped to the page edge on their own line via caretRangeFromPoint. // re-mapped to the page edge on their own line via caretRangeFromPoint.