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)
This commit is contained in:
@@ -23,7 +23,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
|
|||||||
|
|
||||||
templ Reader(user User, metadata ReaderMetadata) {
|
templ Reader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="overscroll-none">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<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
|
<body
|
||||||
x-data="readerShell"
|
x-data="readerShell"
|
||||||
x-init={ readerInitExpr(metadata) }
|
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;
|
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
||||||
drawers and scrim float above everything. -->
|
drawers and scrim float above everything. -->
|
||||||
|
|||||||
+269
-44
@@ -358,6 +358,15 @@ const getCSS = ({
|
|||||||
text-align: ${justify ? "justify" : "start"} !important;
|
text-align: ${justify ? "justify" : "start"} !important;
|
||||||
hyphens: ${hyphenate ? "auto" : "none"};
|
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="left"] { text-align: left; }
|
||||||
[align="right"] { text-align: right; }
|
[align="right"] { text-align: right; }
|
||||||
[align="center"] { text-align: center; }
|
[align="center"] { text-align: center; }
|
||||||
@@ -404,12 +413,20 @@ document.addEventListener("alpine:init", () => {
|
|||||||
pointerFine: true as boolean,
|
pointerFine: true as boolean,
|
||||||
fxZoomed: false as boolean,
|
fxZoomed: false as boolean,
|
||||||
tapZonesEnabled: true as boolean,
|
tapZonesEnabled: true as boolean,
|
||||||
tapZoneSize: 30 as number,
|
tapZoneSize: 12 as number,
|
||||||
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
// Any live text selection, host document or content iframe. Fed by
|
// Any live text selection, host document or content iframe. Fed by
|
||||||
// selectionchange listeners (touch devices); tap zones stand down
|
// selectionchange listeners (touch devices); tap zones stand down
|
||||||
// while one exists.
|
// while one exists.
|
||||||
anySelection: false as boolean,
|
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 {
|
highlightItems: [] as {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -598,7 +615,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.chromeBehavior =
|
this.chromeBehavior =
|
||||||
behavior === "always-visible" ? "always-visible" : "auto-hide";
|
behavior === "always-visible" ? "always-visible" : "auto-hide";
|
||||||
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
|
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;
|
const mode = this.settings.pdf_interaction_mode;
|
||||||
if (mode === "pan" || mode === "text" || mode === "select") {
|
if (mode === "pan" || mode === "text" || mode === "select") {
|
||||||
this.interactionMode = mode;
|
this.interactionMode = mode;
|
||||||
@@ -679,6 +696,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.view.addEventListener("load", (e: any) => {
|
this.view.addEventListener("load", (e: any) => {
|
||||||
const { doc, index } = e.detail;
|
const { doc, index } = e.detail;
|
||||||
|
this.currentDoc = doc;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = "/static/reader-fonts.css";
|
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).
|
// out to the host document, so the viewport listeners miss them).
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) {
|
if (window.matchMedia("(pointer: coarse)").matches) {
|
||||||
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
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<typeof setTimeout> | 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
|
// Same for selectionchange: a selection inside the iframe must
|
||||||
// cancel armed tap actions and feed the host-surface guard.
|
// cancel armed tap actions and feed the host-surface guard.
|
||||||
doc.addEventListener("selectionchange", () => {
|
doc.addEventListener("selectionchange", () => {
|
||||||
@@ -702,49 +742,41 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.noteSelectionActivity(
|
this.noteSelectionActivity(
|
||||||
!!sel && !sel.isCollapsed && !!sel.toString(),
|
!!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;
|
// Text selection → highlight popover (reflowable EPUB only;
|
||||||
// fixed-layout highlight overlays are a later milestone).
|
// fixed-layout highlight overlays are a later milestone).
|
||||||
if (!this.isFixedLayout) {
|
if (!this.isFixedLayout) {
|
||||||
const checkSelection = () => {
|
const checkSelection = (e?: PointerEvent) => {
|
||||||
const sel = doc.getSelection();
|
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 (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||||
if (this.selectionPopover.mode === "create")
|
if (this.selectionPopover.mode === "create")
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const range = sel.getRangeAt(0);
|
this.openPopoverForSelection(sel, doc, index);
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"pointerup",
|
"pointerup",
|
||||||
() => setTimeout(checkSelection, 0),
|
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
// Clicks in the book dismiss the popover in ANY mode: iframe
|
// Clicks in the book dismiss the popover in ANY mode: iframe
|
||||||
@@ -1080,6 +1112,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
downId = e.pointerId;
|
downId = e.pointerId;
|
||||||
moved = false;
|
moved = false;
|
||||||
longPressed = false;
|
longPressed = false;
|
||||||
|
this.lastTouchDownT = downT;
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
@@ -1121,7 +1154,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
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
|
// Host-surface blind spot: selections living in content iframes
|
||||||
// (or the host's own fixed-layout text layer) never show in a
|
// (or the host's own fixed-layout text layer) never show in a
|
||||||
// per-surface check — the tracked flag covers them.
|
// 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
|
// No tap actions while a fixed-layout page is zoomed — taps then
|
||||||
// belong to the content (and double-tap zoom).
|
// belong to the content (and double-tap zoom).
|
||||||
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
||||||
const width =
|
// Content iframes are laid out at the full section width (the
|
||||||
(isDoc
|
// paginator shows one column slice of a much wider document), so
|
||||||
? (surface as any).documentElement.clientWidth
|
// clientX must be mapped into the visible page before it can be
|
||||||
: (surface as HTMLElement).getBoundingClientRect().width) || 1;
|
// compared against the tap zones — dividing by the iframe's
|
||||||
const relX =
|
// clientWidth puts every tap in the left zone.
|
||||||
(e.clientX -
|
let relX: number;
|
||||||
(isDoc
|
if (isDoc) {
|
||||||
? 0
|
const r: any = this.renderer;
|
||||||
: (surface as HTMLElement).getBoundingClientRect().left)) /
|
const docWidth = (surface as any).documentElement.clientWidth || 1;
|
||||||
width;
|
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);
|
this.routeTapDebounced(relX);
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
@@ -1199,10 +1245,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
p.noteOpen = !!p.note && opts.mode === "edit";
|
p.noteOpen = !!p.note && opts.mode === "edit";
|
||||||
p.pdfPage = opts.pdfPage ?? -1;
|
p.pdfPage = opts.pdfPage ?? -1;
|
||||||
p.pdfRects = opts.pdfRects ?? [];
|
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 w = window.innerWidth;
|
||||||
const h = window.innerHeight;
|
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.y = Math.min(Math.max(opts.y, 60), h - 60);
|
||||||
p.open = true;
|
p.open = true;
|
||||||
},
|
},
|
||||||
@@ -1210,6 +1260,174 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.selectionPopover.open = false;
|
this.selectionPopover.open = false;
|
||||||
this.selectionPopover.noteOpen = 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() {
|
renderAllHighlights() {
|
||||||
for (const hl of this.highlightItems) {
|
for (const hl of this.highlightItems) {
|
||||||
if (hl.pdfPage >= 0) {
|
if (hl.pdfPage >= 0) {
|
||||||
@@ -1403,6 +1621,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
note: "",
|
note: "",
|
||||||
id: row.id,
|
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();
|
this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user