Compare commits

...
5 Commits
Author SHA1 Message Date
John O'Keefe 27a93ba2ea fix(reader): hide the chrome when a touch selection starts
Release / build-and-push (push) Successful in 2m28s
pokeChrome's anySelection gate only prevents the chrome from being
raised — if it was already visible when the selection began, it
stayed up. noteSelectionActivity now actively hides it the moment
a selection appears.
2026-09-15 20:20:34 -04:00
John O'Keefe 7afad5dcde chore: point foliate-js at linuxhg mirror main (8a20399) 2026-09-15 20:13:07 -04:00
John O'Keefe 929a862e74 feat(reader): touch selection handles, copy fallback, highlight tap editing
- Custom drag handles (start/end) for post-lift selection adjustment:
  the native handles are disabled with the rest of the native touch
  selection controller; these are positioned at the selection's
  boundary carets and driven through the same clamped caret mapping
- Copy button falls back to execCommand via a transient textarea on
  plain HTTP (navigator.clipboard is unavailable on LAN addresses);
  both paths clear the selection and dismiss the popover
- Only dismiss the selection popover on actual position changes in
  relocate (detail.section is a fresh object on every relocate, so
  reference comparison always saw a move); a tap's no-op relocate
  was closing the just-opened highlight edit popover
- Pin foliate-js f872a01 (synthetic click dispatch on quick taps)
  and 422e8e0 (don't snap taps that never panned)
2026-09-15 19:33:45 -04:00
John O'Keefe 0e55dafb1e 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)
2026-09-15 08:50:17 -04:00
John O'Keefe cc8084a713 fix(reader): pin foliate-js 9cfe266
Non-touch gate on selection auto-paging (drag-selecting no longer
pages away mid-gesture) and small height-only viewport resizes no
longer re-wrap the book (mobile browser toolbar transitions).
2026-09-15 08:50:06 -04:00
4 changed files with 475 additions and 51 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36",
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#8a20399",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"highlight.js": "^11.11.1",
+2 -2
View File
@@ -23,7 +23,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="overscroll-none">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
@@ -38,7 +38,7 @@ templ Reader(user User, metadata ReaderMetadata) {
<body
x-data="readerShell"
x-init={ readerInitExpr(metadata) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
class={ "theme-" + user.Theme + " h-screen overflow-hidden overscroll-none" }
>
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
drawers and scrim float above everything. -->
+441 -48
View File
@@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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";
@@ -702,49 +720,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
@@ -885,7 +895,18 @@ document.addEventListener("alpine:init", () => {
this.view.addEventListener("relocate", (e: any) => {
const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail;
this.hideSelectionPopover();
// Only dismiss the popover on an actual position change — the
// paginator also emits no-op relocates (e.g. a tap's page-aligned
// anchor), which would otherwise close a just-opened highlight
// edit popover. NOTE: detail.section is an object (recreated on
// every relocate), so it can't be compared by reference; the
// fraction threshold alone covers section changes (any section
// boundary moves the whole-book fraction far more than 0.1%).
const prev = this.lastRelocateDetail;
const moved =
!prev ||
Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001;
if (moved) this.hideSelectionPopover();
this.lastCfi = cfi || "";
this.lastRelocateDetail = {
fraction,
@@ -1006,6 +1027,10 @@ document.addEventListener("alpine:init", () => {
this.pokeChrome();
},
pokeChrome() {
// Never raise the chrome while a touch selection is live — the
// selection popover owns the screen until the selection is
// consumed or dismissed.
if (this.anySelection) return;
this.chromeVisible = true;
if (this.chromeBehavior !== "auto-hide") return;
if (this.hideTimer) clearTimeout(this.hideTimer);
@@ -1057,6 +1082,15 @@ document.addEventListener("alpine:init", () => {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
// Hide the chrome the moment a selection appears — it competes
// with the selection popover and handles for screen space.
if (hasSelection && this.chromeVisible) {
this.chromeVisible = false;
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
}
},
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0;
@@ -1080,6 +1114,7 @@ document.addEventListener("alpine:init", () => {
downId = e.pointerId;
moved = false;
longPressed = false;
this.lastTouchDownT = downT;
},
{ passive: true },
);
@@ -1129,16 +1164,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 },
@@ -1154,6 +1199,13 @@ document.addEventListener("alpine:init", () => {
}
this.tapZoneTimer = setTimeout(() => {
this.tapZoneTimer = null;
// Touch pages by swipe only (the paginator's pan + snap); a tap
// anywhere just toggles the chrome. The edge zones remain for
// mouse-driven taps.
if (window.matchMedia("(pointer: coarse)").matches) {
this.toggleChromeManually();
return;
}
const zone = this.tapZoneSize / 100;
if (relX < zone) this.goLeft();
else if (relX > 1 - zone) this.goRight();
@@ -1199,10 +1251,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 +1266,178 @@ 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();
this.hideSelectionHandles();
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; the drag handles occupy the first 24px below
// the selection, so the offset must clear them too.
const coarse = window.matchMedia("(pointer: coarse)").matches;
const y = coarse
? (iframeRect?.top ?? 0) + rect.bottom + 90
: (iframeRect?.top ?? 0) + rect.top;
this.openSelectionPopover({
mode: "create",
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
y,
text,
cfi,
cfiEnd,
});
if (coarse && this.view?.book?.dir !== "rtl")
this.showSelectionHandles(doc);
},
renderAllHighlights() {
for (const hl of this.highlightItems) {
if (hl.pdfPage >= 0) {
@@ -1403,6 +1631,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) {
@@ -1504,12 +1739,170 @@ document.addEventListener("alpine:init", () => {
}
},
async copySelectionText() {
const text = this.selectionPopover.text;
try {
await navigator.clipboard.writeText(this.selectionPopover.text);
this.hideSelectionPopover();
} catch (_e) {
/* clipboard unavailable */
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
this.finishCopy();
return;
}
} catch {
/* fall through to the legacy path */
}
// Plain HTTP (typical LAN access) has no navigator.clipboard;
// a transient textarea + execCommand works from this user
// gesture on insecure contexts.
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.append(ta);
ta.select();
document.execCommand("copy");
ta.remove();
this.finishCopy();
} catch {
/* ignore clipboard errors */
}
},
finishCopy() {
try {
this.currentDoc?.getSelection()?.removeAllRanges();
} catch {
/* doc may be gone */
}
this.hideSelectionPopover();
},
// ----- touch selection handles -----
// The native selection handles cannot be used (the custom gesture's
// touchstart preventDefault disables them along with the rest of the
// native controller), so adjustment after lift happens through these.
// Purely imperative DOM — position updates run on every drag frame.
selectionHandles: {
root: null as HTMLElement | null,
start: null as HTMLElement | null,
end: null as HTMLElement | null,
drag: null as "start" | "end" | null,
doc: null as any,
},
ensureSelectionHandles() {
const h = this.selectionHandles as any;
if (h.root) return;
const root = document.createElement("div");
root.id = "selection-handles";
const make = (which: "start" | "end") => {
const el = document.createElement("div");
el.className = "sel-handle";
el.addEventListener("pointerdown", (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
h.drag = which;
el.setPointerCapture(e.pointerId);
});
el.addEventListener("pointermove", (e: PointerEvent) => {
if (h.drag !== which) return;
e.preventDefault();
this.dragSelectionHandle(e);
});
const release = () => {
h.drag = null;
};
el.addEventListener("pointerup", release);
el.addEventListener("pointercancel", release);
root.append(el);
return el;
};
h.start = make("start");
h.end = make("end");
h.root = root;
document.body.append(root);
},
showSelectionHandles(doc: any) {
this.ensureSelectionHandles();
const h = this.selectionHandles as any;
h.doc = doc;
h.root!.style.display = "block";
this.positionSelectionHandles(doc);
},
hideSelectionHandles() {
const h = this.selectionHandles as any;
if (!h.root) return;
h.root.style.display = "none";
h.drag = null;
},
// Host-space position of a selection boundary: the left edge of the
// start caret or the right edge of the end caret, at the line bottom.
caretHostPos(doc: any, node: any, offset: number, forward: boolean) {
try {
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();
const r = rects[forward ? 0 : rects.length - 1];
if (!r) return null;
const off = doc.defaultView?.frameElement?.getBoundingClientRect();
return {
x: (off?.left ?? 0) + (forward ? r.left : r.right),
y: (off?.top ?? 0) + r.bottom,
};
} catch {
return null;
}
},
positionSelectionHandles(doc: any) {
const h = this.selectionHandles as any;
if (!h.root || h.root.style.display === "none") return;
const sel = doc.getSelection();
if (!sel?.rangeCount || sel.isCollapsed) {
this.hideSelectionHandles();
return;
}
const range = sel.getRangeAt(0);
const startPos = this.caretHostPos(doc, range.startContainer,
range.startOffset, true);
const endPos = this.caretHostPos(doc, range.endContainer,
range.endOffset, false);
if (!startPos || !endPos) {
this.hideSelectionHandles();
return;
}
h.start!.style.transform = `translate(${startPos.x - 6}px, ${startPos.y}px)`;
h.end!.style.transform = `translate(${endPos.x - 6}px, ${endPos.y}px)`;
},
dragSelectionHandle(ev: PointerEvent) {
const h = this.selectionHandles as any;
const doc = h.doc;
const r: any = this.renderer;
if (!doc || !r || r.scrolled) return;
if (this.view?.book?.dir === "rtl") return;
const frame = doc.defaultView?.frameElement;
if (!frame) return;
const off = frame.getBoundingClientRect();
// pointer → iframe content space, clamped to the visible page
const size = r.size;
const left = r.start - size;
const right = r.start;
const x = Math.min(Math.max(ev.clientX - off.left, left + 2), right - 2);
const y = ev.clientY - off.top;
const caret = doc.caretRangeFromPoint?.(x, y)
?? doc.caretPositionFromPoint?.(x, y);
if (!caret) return;
const node = caret.startContainer ?? caret.offsetNode;
const offset = caret.startOffset ?? caret.offset;
const sel = doc.getSelection();
if (!sel?.rangeCount || !node) return;
const range = sel.getRangeAt(0);
if (h.drag === "start")
sel.setBaseAndExtent(node, offset, range.endContainer, range.endOffset);
else
sel.setBaseAndExtent(range.startContainer, range.startOffset,
node, offset);
this.positionSelectionHandles(doc);
},
goToHighlight(hl: {
cfi: string;
+31
View File
@@ -1434,3 +1434,34 @@
}
}
}
/* Touch selection handles (custom gesture — the native ones are disabled) */
#selection-handles {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 55;
}
#selection-handles .sel-handle {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 24px;
pointer-events: auto;
touch-action: none;
background: #8ab4f8;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
opacity: 0.95;
}
#selection-handles .sel-handle::before {
content: "";
position: absolute;
top: -4px;
left: 50%;
width: 2px;
height: 5px;
background: inherit;
transform: translateX(-50%);
}