From 0e55dafb1ee43ab034e316142839a01eb76318df Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 08:50:17 -0400 Subject: [PATCH] fix(reader): mobile touch reading overhaul - tap zones: map in-iframe taps into the visible page slice (the iframe is laid out at full section width; every tap previously computed as the left margin) and shrink the default zone size to 12% - hyphens: manual on coarse pointers: Chrome's touch word-selection walks hyphen fragments, re-anchoring line-start drags and overshooting line ends into the next column - selection popover: opens for settled touch selections (the gesture takeover swallows pointerup), positioned below the selection with on-screen clamping; creating a highlight clears the selection so the browser's own menu follows - settle-time recovery: return the view to the selection's anchor page and clamp the selection to the visible page after the browser's selection auto-scroll wanders off the page grid - overscroll-behavior: none on the reader page (pull-to-refresh during downward drags) --- templates/reader.templ | 4 +- web/src/reader/reader.ts | 313 +++++++++++++++++++++++++++++++++------ 2 files changed, 271 insertions(+), 46 deletions(-) diff --git a/templates/reader.templ b/templates/reader.templ index e866618..b645770 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -23,7 +23,7 @@ func readerInitExpr(metadata ReaderMetadata) string { templ Reader(user User, metadata ReaderMetadata) { - + @@ -38,7 +38,7 @@ templ Reader(user User, metadata ReaderMetadata) { diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index d1af444..6667935 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -358,6 +358,15 @@ const getCSS = ({ text-align: ${justify ? "justify" : "start"} !important; hyphens: ${hyphenate ? "auto" : "none"}; } + /* Chrome's touch word-selection walks hyphen fragments: dragging from a + line-start word re-anchors at the line-end fragment (silently dropping + the selected word) and fast drags overshoot into the next column. + Coarse pointers get whole words only. */ + @media (pointer: coarse) { + p, li, blockquote, dd { + hyphens: ${hyphenate ? "manual" : "none"}; + } + } [align="left"] { text-align: left; } [align="right"] { text-align: right; } [align="center"] { text-align: center; } @@ -404,12 +413,20 @@ document.addEventListener("alpine:init", () => { pointerFine: true as boolean, fxZoomed: false as boolean, tapZonesEnabled: true as boolean, - tapZoneSize: 30 as number, + tapZoneSize: 12 as number, tapZoneTimer: null as ReturnType | null, // Any live text selection, host document or content iframe. Fed by // selectionchange listeners (touch devices); tap zones stand down // while one exists. anySelection: false as boolean, + // The section document most recently loaded (for clearing its + // selection after actions that consume it). + currentDoc: null as any, + // Timestamp of the last touch press (pointer:coarse surfaces); a + // selection appearing during a short press is Chrome's tap-to-select, + // not a selection gesture. + lastTouchDownT: 0 as number, + selPopTimer: null as ReturnType | null, highlightItems: [] as { id: string; text: string; @@ -598,7 +615,7 @@ document.addEventListener("alpine:init", () => { this.chromeBehavior = behavior === "always-visible" ? "always-visible" : "auto-hide"; this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true; - this.tapZoneSize = this.settings.tap_zone_size || 30; + this.tapZoneSize = this.settings.tap_zone_size || 12; const mode = this.settings.pdf_interaction_mode; if (mode === "pan" || mode === "text" || mode === "select") { this.interactionMode = mode; @@ -679,6 +696,7 @@ document.addEventListener("alpine:init", () => { } this.view.addEventListener("load", (e: any) => { const { doc, index } = e.detail; + this.currentDoc = doc; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; @@ -695,6 +713,28 @@ document.addEventListener("alpine:init", () => { // out to the host document, so the viewport listeners miss them). if (window.matchMedia("(pointer: coarse)").matches) { this.attachTapZoneListeners(doc as unknown as HTMLElement, true); + // Chrome (notably on older builds) selects the word under a + // quick tap and then raises its own search/selection sheet for + // it. Make the document unselectable for the first moments of + // every touch so a tap cannot create a selection at all; the + // long-press selection engages later (~500ms), well after the + // style is restored. + let tapGuardTimer: ReturnType | null = null; + doc.addEventListener( + "touchstart", + () => { + const el = doc.documentElement; + el.style.userSelect = "none"; + (el.style as any).webkitUserSelect = "none"; + if (tapGuardTimer) clearTimeout(tapGuardTimer); + tapGuardTimer = setTimeout(() => { + tapGuardTimer = null; + el.style.userSelect = ""; + (el.style as any).webkitUserSelect = ""; + }, 350); + }, + { passive: true }, + ); // Same for selectionchange: a selection inside the iframe must // cancel armed tap actions and feed the host-surface guard. doc.addEventListener("selectionchange", () => { @@ -702,49 +742,41 @@ document.addEventListener("alpine:init", () => { this.noteSelectionActivity( !!sel && !sel.isCollapsed && !!sel.toString(), ); + // The gesture takeover swallows pointerup for touch selection + // 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) + this.scheduleSelectionPopover(doc, index); }); } // Text selection → highlight popover (reflowable EPUB only; // fixed-layout highlight overlays are a later milestone). if (!this.isFixedLayout) { - const checkSelection = () => { + const checkSelection = (e?: PointerEvent) => { const sel = doc.getSelection(); + // 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. Real hold selections never reach this pointerup + // (the gesture takeover swallows it) — they open the popover + // through the selectionchange debounce below. + if ( + e && e.pointerType === "touch" && this.lastTouchDownT && + Date.now() - this.lastTouchDownT < 300 + ) { + sel?.removeAllRanges(); + return; + } if (!sel || sel.isCollapsed || !sel.rangeCount) { if (this.selectionPopover.mode === "create") this.hideSelectionPopover(); return; } - const range = sel.getRangeAt(0); - const text = sel.toString().replace(/\s+/g, " ").trim(); - if (!text) return; - let cfi: string; - let cfiEnd: string; - try { - cfi = this.view.getCFI(index, range); - // Collapse to the end point for a distinct end anchor — - // KOReader sync renders the highlight box from pos0/pos1, and - // pos1 == pos0 would be a degenerate (zero-length) range. - const endRange = range.cloneRange(); - endRange.collapse(false); - cfiEnd = this.view.getCFI(index, endRange); - } catch { - return; - } - const frame = doc.defaultView?.frameElement as HTMLElement | null; - const iframeRect = frame?.getBoundingClientRect(); - const rect = range.getBoundingClientRect(); - this.openSelectionPopover({ - mode: "create", - x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2, - y: (iframeRect?.top ?? 0) + rect.top, - text, - cfi, - cfiEnd, - }); + this.openPopoverForSelection(sel, doc, index); }; doc.addEventListener( "pointerup", - () => setTimeout(checkSelection, 0), + (e: PointerEvent) => setTimeout(() => checkSelection(e), 0), { passive: true }, ); // Clicks in the book dismiss the popover in ANY mode: iframe @@ -1080,6 +1112,7 @@ document.addEventListener("alpine:init", () => { downId = e.pointerId; moved = false; longPressed = false; + this.lastTouchDownT = downT; }, { passive: true }, ); @@ -1121,7 +1154,10 @@ document.addEventListener("alpine:init", () => { ) return; const sel = isDoc ? (surface as any).getSelection?.() : null; - if (sel?.toString?.()) return; + // Chrome on touch selects the word under a quick tap; only stand + // down for a selection made by an actual hold — the spurious + // tap selections are cleared by the popover's pointerup path. + if (sel?.toString?.() && Date.now() - downT >= 300) return; // Host-surface blind spot: selections living in content iframes // (or the host's own fixed-layout text layer) never show in a // per-surface check — the tracked flag covers them. @@ -1129,16 +1165,26 @@ document.addEventListener("alpine:init", () => { // No tap actions while a fixed-layout page is zoomed — taps then // belong to the content (and double-tap zoom). if (this.isFixedLayout && this.renderer?.zoom != null) return; - const width = - (isDoc - ? (surface as any).documentElement.clientWidth - : (surface as HTMLElement).getBoundingClientRect().width) || 1; - const relX = - (e.clientX - - (isDoc - ? 0 - : (surface as HTMLElement).getBoundingClientRect().left)) / - width; + // Content iframes are laid out at the full section width (the + // paginator shows one column slice of a much wider document), so + // clientX must be mapped into the visible page before it can be + // compared against the tap zones — dividing by the iframe's + // clientWidth puts every tap in the left zone. + let relX: number; + if (isDoc) { + const r: any = this.renderer; + const docWidth = (surface as any).documentElement.clientWidth || 1; + if (r && !r.scrolled && r.size) { + const base = r.start - r.size; + relX = (e.clientX - base) / r.size; + } else { + relX = e.clientX / docWidth; + } + if (this.view?.book?.dir === "rtl") relX = 1 - relX; + } else { + const rect = (surface as HTMLElement).getBoundingClientRect(); + relX = (e.clientX - rect.left) / (rect.width || 1); + } this.routeTapDebounced(relX); }, { passive: true }, @@ -1199,10 +1245,14 @@ document.addEventListener("alpine:init", () => { p.noteOpen = !!p.note && opts.mode === "edit"; p.pdfPage = opts.pdfPage ?? -1; p.pdfRects = opts.pdfRects ?? []; - // Clamp so the popover stays on screen (it anchors bottom-center). + // Clamp so the popover stays on screen (it anchors bottom-center; + // the popover is ~240px wide, so the fixed margins must cover half + // of it). NOTE: do not mutate the position after opening — touching + // the style mid-enter-transition leaves the popover invisible to + // compositor hit-testing (taps fall through to the content iframe). const w = window.innerWidth; const h = window.innerHeight; - p.x = Math.min(Math.max(opts.x, 90), w - 90); + p.x = Math.min(Math.max(opts.x, 130), w - 130); p.y = Math.min(Math.max(opts.y, 60), h - 60); p.open = true; }, @@ -1210,6 +1260,174 @@ document.addEventListener("alpine:init", () => { this.selectionPopover.open = false; this.selectionPopover.noteOpen = false; }, + scheduleSelectionPopover(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; + } + // The browser's selection auto-scroll (drags near a viewport + // edge) scrolls the paginated container off the page grid, and + // a selection that crosses the page edge keeps its reveal-scroll + // chasing the off-page text — the view bounces between pages + // forever. Correct the view to the anchor's page and clamp the + // selection to the visible page; scrollBy clamps to the current + // section, so the correction can never cross sections. + const r: any = this.renderer; + if (r && !r.scrolled && r.size && this.view?.book?.dir !== "rtl") { + try { + const anchorProbe = doc.createRange(); + anchorProbe.setStart(sel.anchorNode, sel.anchorOffset); + // collapsed ranges report degenerate (0,0) rects — extend by + // one character to get a measurable one + if ( + sel.anchorNode?.nodeType === 3 && + sel.anchorOffset < sel.anchorNode.length + ) + anchorProbe.setEnd(sel.anchorNode, sel.anchorOffset + 1); + const rects = anchorProbe.getClientRects(); + const ar = + rects[rects.length - 1] || anchorProbe.getBoundingClientRect(); + // rect x is already in the iframe's own (content) space, + // i.e. scroll-invariant: page = floor(x / column stride) + const desired = (Math.floor(ar.x / r.size) + 1) * r.size; + const needsScroll = Math.abs(r.start - desired) > r.size * 0.02; + if (needsScroll) r.scrollBy(desired - r.start, 0); + const clamped = this.clampSelectionToPage(sel, doc, r); + if (clamped) + // the clamp's selectionchange re-arms this settle; the + // next pass finds view and selection in place and opens + // the popover + return; + if (needsScroll) { + setTimeout(() => { + const s = doc.getSelection(); + if (s && s.rangeCount && !s.isCollapsed && s.type === "Range") + this.openPopoverForSelection(s, doc, index); + }, 350); + return; + } + } catch { + /* anchor geometry unavailable */ + } + } + this.openPopoverForSelection(sel, doc, index); + }, 400); + }, + // 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. + clampSelectionToPage(sel: any, doc: any, r: any): boolean { + const left = r.start - r.size; + const right = r.start; + const TOL = 8; + const backwardProbe = doc.createRange(); + backwardProbe.setStart(sel.anchorNode, sel.anchorOffset); + backwardProbe.setEnd(sel.focusNode, sel.focusOffset); + const backward = backwardProbe.collapsed; + const range = sel.getRangeAt(0); + const probeRect = (node: any, offset: number, forward: boolean) => { + const p = doc.createRange(); + p.setStart(node, offset); + if (node?.nodeType === 3) { + if (forward && offset < node.length) + p.setEnd(node, offset + 1); + else if (!forward && offset > 0) + p.setStart(node, offset - 1); + } + if (p.collapsed) return null; + const rects = p.getClientRects(); + return rects.length ? rects[forward ? 0 : rects.length - 1] : null; + }; + const caretAt = (x: number, y: number) => { + const caret = doc.caretRangeFromPoint?.(x, y) + ?? doc.caretPositionFromPoint?.(x, y); + if (!caret) return null; + return { + node: caret.startContainer ?? caret.offsetNode, + offset: caret.startOffset ?? caret.offset, + }; + }; + // selection end beyond the visible page (into the next column): + // trim to the last position on the visible page (bottom-right + // corner of the column — nearest-text mapping is unambiguous + // from the margin) + const endRect = probeRect(range.endContainer, range.endOffset, false); + if (endRect && endRect.x + endRect.width > right + TOL) { + const caret = caretAt( + right - 15, doc.documentElement.clientHeight - 40); + if (caret && range.isPointInRange(caret.node, caret.offset)) { + if (backward) + sel.setBaseAndExtent(caret.node, caret.offset, + sel.focusNode, sel.focusOffset); + else sel.extend(caret.node, caret.offset); + return true; + } + } + // selection start before the visible page (previous column): + // trim to the first position on the visible page (top-left corner) + const startRect = probeRect(range.startContainer, range.startOffset, true); + if (startRect && startRect.x < left - TOL) { + const caret = caretAt(left + 15, 40); + if (caret && range.isPointInRange(caret.node, caret.offset)) { + if (backward) + sel.extend(caret.node, caret.offset); + else + sel.setBaseAndExtent(caret.node, caret.offset, + sel.focusNode, sel.focusOffset); + return true; + } + } + return false; + }, + openPopoverForSelection(sel: any, doc: any, index: number) { + const range = sel.getRangeAt(0); + const text = sel.toString().replace(/\s+/g, " ").trim(); + if (!text) return; + let cfi: string; + let cfiEnd: string; + try { + cfi = this.view.getCFI(index, range); + // Collapse to the end point for a distinct end anchor — + // KOReader sync renders the highlight box from pos0/pos1, and + // pos1 == pos0 would be a degenerate (zero-length) range. + const endRange = range.cloneRange(); + endRange.collapse(false); + cfiEnd = this.view.getCFI(index, endRange); + } catch { + return; + } + const frame = doc.defaultView?.frameElement as HTMLElement | null; + const iframeRect = frame?.getBoundingClientRect(); + const rect = range.getBoundingClientRect(); + // On touch the browser's own selection menu attaches ABOVE the + // selection (and paints over page content) — place ours BELOW it + // so both are visible. The popover anchors bottom-center at y-10 + // and is ~44px tall, hence the offset. + const coarse = window.matchMedia("(pointer: coarse)").matches; + const y = coarse + ? (iframeRect?.top ?? 0) + rect.bottom + 64 + : (iframeRect?.top ?? 0) + rect.top; + this.openSelectionPopover({ + mode: "create", + x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2, + y, + text, + cfi, + cfiEnd, + }); + }, renderAllHighlights() { for (const hl of this.highlightItems) { if (hl.pdfPage >= 0) { @@ -1403,6 +1621,13 @@ document.addEventListener("alpine:init", () => { note: "", id: row.id, }); + // Clear the DOM selection: the gesture is consumed, and it is + // what keeps Chrome's native copy/share menu on screen. + try { + this.currentDoc?.getSelection()?.removeAllRanges(); + } catch { + /* doc may be gone */ + } } this.hideSelectionPopover(); } catch (_e) {