Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 | ||
|
|
27a93ba2ea | ||
|
|
7afad5dcde | ||
|
|
929a862e74 | ||
|
|
0e55dafb1e | ||
|
|
cc8084a713 |
+4
-1
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
|
||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||
# could not evict; a new mount id guarantees a clean fetch.
|
||||
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
|
||||
+1
-1
@@ -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#1305a52",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
+10
-4
@@ -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. -->
|
||||
@@ -139,7 +139,13 @@ templ Reader(user User, metadata ReaderMetadata) {
|
||||
:style="'background-color:' + c"
|
||||
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
||||
:aria-label="'Highlight color ' + c"
|
||||
@click="selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())"
|
||||
@click="selectionPopover.noteOpen
|
||||
? (selectionPopover.mode === 'create'
|
||||
? (selectionPopover.color = c)
|
||||
: (selectionPopover.color = c, saveHighlightChanges(true)))
|
||||
: (selectionPopover.mode === 'create'
|
||||
? createHighlight(c)
|
||||
: (selectionPopover.color = c, saveHighlightChanges()))"
|
||||
></button>
|
||||
</template>
|
||||
<div class="w-px h-5 reader-sep"></div>
|
||||
@@ -188,7 +194,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
||||
type="button"
|
||||
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
||||
style="border-color: var(--border);"
|
||||
@click="selectionPopover.noteOpen = false"
|
||||
@click="hideSelectionPopover()"
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,14 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\"><title>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><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\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 30, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 30, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -67,7 +67,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden overscroll-none"}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
@@ -79,7 +79,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 40, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 40, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -92,7 +92,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -122,7 +122,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\"></textarea><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"selectionPopover.noteOpen = false\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.noteOpen\n\t\t\t\t\t\t\t? (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? (selectionPopover.color = c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges(true)))\n\t\t\t\t\t\t\t: (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? createHighlight(c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges()))\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\"></textarea><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 292, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 298, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -187,7 +187,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 295, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 301, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -229,7 +229,7 @@ func drawerHeader(title string) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 555, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 561, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+569
-124
@@ -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,64 +720,57 @@ 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 && doc.querySelector(".textLayer"))
|
||||
this.schedulePDFSelectionPopover(doc, index);
|
||||
else if (!this.isFixedLayout)
|
||||
this.scheduleSelectionPopover(doc, index);
|
||||
});
|
||||
}
|
||||
// Clicks in the book dismiss the popover in ANY mode and for ANY
|
||||
// content doc (EPUB sections and fixed-layout pages alike): iframe
|
||||
// events never bubble to the host document (so the host
|
||||
// outside-click dismiss never sees them). Clicking a painted
|
||||
// highlight still works: this hides, then foliate's
|
||||
// show-annotation re-opens it in edit mode.
|
||||
doc.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
if (this.selectionPopover.open)
|
||||
this.hideSelectionPopover();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// 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),
|
||||
{ passive: true },
|
||||
);
|
||||
// Clicks in the book dismiss the popover in ANY mode: iframe
|
||||
// events never bubble to the host document (so the host
|
||||
// outside-click dismiss never sees them), and the collapsed
|
||||
// check above only covers create mode — edit mode had no
|
||||
// outside-click path at all, leaving Esc as the only way out.
|
||||
// Clicking a painted highlight still works: this hides, then
|
||||
// foliate's show-annotation re-opens it in edit mode.
|
||||
doc.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
if (this.selectionPopover.open)
|
||||
this.hideSelectionPopover();
|
||||
},
|
||||
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
||||
{ passive: true },
|
||||
);
|
||||
doc.addEventListener(
|
||||
@@ -775,65 +786,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 <html> 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 },
|
||||
);
|
||||
}
|
||||
@@ -885,7 +854,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 +986,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 +1041,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 +1073,7 @@ document.addEventListener("alpine:init", () => {
|
||||
downId = e.pointerId;
|
||||
moved = false;
|
||||
longPressed = false;
|
||||
this.lastTouchDownT = downT;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -1129,16 +1123,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 +1158,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 +1210,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 +1225,266 @@ 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);
|
||||
},
|
||||
// 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 <html> 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.
|
||||
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) {
|
||||
@@ -1351,6 +1626,9 @@ document.addEventListener("alpine:init", () => {
|
||||
const p = this.selectionPopover;
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
|
||||
// The note editor writes into p.note live; "Highlight with note"
|
||||
// creates the highlight AND its note in one POST.
|
||||
const note = p.note.trim();
|
||||
const pdfAnchor =
|
||||
p.pdfPage >= 0
|
||||
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
||||
@@ -1371,7 +1649,7 @@ document.addEventListener("alpine:init", () => {
|
||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||
color,
|
||||
note_text: "",
|
||||
note_text: note,
|
||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||
}),
|
||||
},
|
||||
@@ -1400,16 +1678,23 @@ document.addEventListener("alpine:init", () => {
|
||||
? ""
|
||||
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||
color,
|
||||
note: "",
|
||||
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) {
|
||||
/* ignore highlight errors */
|
||||
}
|
||||
},
|
||||
async saveHighlightChanges() {
|
||||
async saveHighlightChanges(keepNoteOpen = false) {
|
||||
const p = this.selectionPopover;
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !p.id) return;
|
||||
@@ -1473,7 +1758,9 @@ document.addEventListener("alpine:init", () => {
|
||||
id: p.id,
|
||||
});
|
||||
}
|
||||
p.noteOpen = false;
|
||||
// Explicit saves (the Save button) close the whole popover; color
|
||||
// tweaks made while the note editor is open keep everything open.
|
||||
if (!keepNoteOpen) this.hideSelectionPopover();
|
||||
} catch (_e) {
|
||||
/* ignore highlight errors */
|
||||
}
|
||||
@@ -1504,12 +1791,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;
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user