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) {