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:
+106
-58
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user