diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 6be8972..a5797ed 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -724,7 +724,9 @@ document.addEventListener("alpine:init", () => { // drags, so the popover's pointerup trigger never fires for // them; a selection that has been stable for a moment is the // 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); }); } @@ -785,65 +787,23 @@ document.addEventListener("alpine:init", () => { // spread renders (during view.init), which is after this listener // attaches — the stale copy here would always be falsy. 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 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( "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 }, ); } @@ -1332,6 +1292,94 @@ document.addEventListener("alpine:init", () => { this.openPopoverForSelection(sel, doc, index); }, 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 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 // occupies content x [start - size, start]; endpoints beyond it are // re-mapped to the page edge on their own line via caretRangeFromPoint.