Release / build-and-push (push) Successful in 3m12s
New wake-lock.ts engine (sanctioned island, dependency-injected for tests): feature-detected navigator.wakeLock, silent no-op when unsupported or denied, and the spec-mandated force-release-on-hide → re-acquire-on-visible loop handled via the sentinel release event. The player acquires on play and releases on pause (sleep-timer fades ride the same path); the reader holds the lock for the session. Both gate on the keep_screen_awake preference — data-keep-awake on the player root, keepScreenAwake in readerInitExpr. Hidden-tab playback intentionally does not keep the screen awake: the spec forbids screen wake locks for hidden documents.
4143 lines
160 KiB
TypeScript
4143 lines
160 KiB
TypeScript
import "foliate-js/view.js";
|
||
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
|
||
import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
|
||
import { Alpine } from "../alpine";
|
||
import { loadSettings, saveSettings } from "./settings-manager";
|
||
import { getToken } from "../storage";
|
||
import { showToast } from "../toast";
|
||
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
|
||
import { extractPdfPages, searchPdfPages, type PdfPageText } from "./pdf-search";
|
||
import { WakeLockManager } from "../wake-lock";
|
||
|
||
const HIGHLIGHT_COLORS = ["#ffd54f", "#a5d6a7", "#90caf9", "#f48fb1", "#ce93d8"];
|
||
|
||
// Rendered page-thumbnail canvases (page index → canvas). Module-level so
|
||
// Alpine reactivity never wraps it and revisiting pages is instant.
|
||
const thumbCache = new Map<number, HTMLCanvasElement>();
|
||
|
||
// Fixed-layout page iframe → its page index. The fixed-layout renderer's
|
||
// getContents() returns {doc} WITHOUT an index (unlike the reflowable
|
||
// renderer), so the rect-edge adjust maps a page index back to its iframe
|
||
// doc through this registry, filled by the view's load events.
|
||
const pdfFrameDocs = new WeakMap<object, number>();
|
||
|
||
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
|
||
|
||
const FONT_MAP: Record<string, string> = {
|
||
literata: '"Literata"',
|
||
crimson: '"Crimson Pro"',
|
||
"source-serif": '"Source Serif 4"',
|
||
"eb-garamond": '"EB Garamond"',
|
||
libertinus: '"Libertinus Serif"',
|
||
"noto-serif": '"Noto Serif"',
|
||
"charis-sil": '"Charis SIL"',
|
||
"ibm-plex": '"IBM Plex Serif"',
|
||
};
|
||
|
||
interface FontFile {
|
||
family: string;
|
||
url: string;
|
||
weight: string;
|
||
style: string;
|
||
}
|
||
|
||
const FONT_FILES: FontFile[] = [
|
||
{
|
||
family: '"Literata"',
|
||
url: "/static/fonts/literata/Literata-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Literata"',
|
||
url: "/static/fonts/literata/Literata-Italic-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Crimson Pro"',
|
||
url: "/static/fonts/crimson/CrimsonPro-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Crimson Pro"',
|
||
url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Source Serif 4"',
|
||
url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Source Serif 4"',
|
||
url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"EB Garamond"',
|
||
url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"EB Garamond"',
|
||
url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Libertinus Serif"',
|
||
url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2",
|
||
weight: "400",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Libertinus Serif"',
|
||
url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2",
|
||
weight: "700",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Libertinus Serif"',
|
||
url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2",
|
||
weight: "400",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Libertinus Serif"',
|
||
url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2",
|
||
weight: "700",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Noto Serif"',
|
||
url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Noto Serif"',
|
||
url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2",
|
||
weight: "100 900",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Charis SIL"',
|
||
url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2",
|
||
weight: "400",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Charis SIL"',
|
||
url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2",
|
||
weight: "700",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"Charis SIL"',
|
||
url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2",
|
||
weight: "400",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"Charis SIL"',
|
||
url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2",
|
||
weight: "700",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"IBM Plex Serif"',
|
||
url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2",
|
||
weight: "400",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"IBM Plex Serif"',
|
||
url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2",
|
||
weight: "700",
|
||
style: "normal",
|
||
},
|
||
{
|
||
family: '"IBM Plex Serif"',
|
||
url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2",
|
||
weight: "400",
|
||
style: "italic",
|
||
},
|
||
{
|
||
family: '"IBM Plex Serif"',
|
||
url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2",
|
||
weight: "700",
|
||
style: "italic",
|
||
},
|
||
];
|
||
|
||
let fontBlobUrls: Record<string, string> = {};
|
||
let fontsLoaded = false;
|
||
|
||
async function loadFontBlobUrls(): Promise<void> {
|
||
if (fontsLoaded) return;
|
||
const entries = await Promise.all(
|
||
FONT_FILES.map(async (f) => {
|
||
try {
|
||
const resp = await fetch(f.url);
|
||
const blob = await resp.blob();
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
return { key: f.url, blobUrl };
|
||
} catch {
|
||
return { key: f.url, blobUrl: f.url };
|
||
}
|
||
}),
|
||
);
|
||
for (const { key, blobUrl } of entries) {
|
||
fontBlobUrls[key] = blobUrl;
|
||
}
|
||
fontsLoaded = true;
|
||
}
|
||
|
||
function buildFontFaceCSS(): string {
|
||
return FONT_FILES.map((f) => {
|
||
const src = fontBlobUrls[f.url] ?? f.url;
|
||
return `@font-face {
|
||
font-family: ${f.family};
|
||
src: url("${src}") format("woff2");
|
||
font-weight: ${f.weight};
|
||
font-style: ${f.style};
|
||
}`;
|
||
}).join("\n");
|
||
}
|
||
|
||
interface ThemeColors {
|
||
fg: string;
|
||
bg: string;
|
||
link: string;
|
||
}
|
||
|
||
const THEME_COLORS: Record<string, { light: ThemeColors; dark: ThemeColors }> = {
|
||
light: {
|
||
light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" },
|
||
dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" },
|
||
},
|
||
paper: {
|
||
light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" },
|
||
dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" },
|
||
},
|
||
sepia: {
|
||
light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" },
|
||
dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" },
|
||
},
|
||
parchment: {
|
||
light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" },
|
||
dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" },
|
||
},
|
||
warm: {
|
||
light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" },
|
||
dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" },
|
||
},
|
||
candlelight: {
|
||
light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" },
|
||
dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" },
|
||
},
|
||
azure: {
|
||
light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" },
|
||
dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" },
|
||
},
|
||
sky: {
|
||
light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" },
|
||
dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" },
|
||
},
|
||
arctic: {
|
||
light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" },
|
||
dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" },
|
||
},
|
||
frost: {
|
||
light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" },
|
||
dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" },
|
||
},
|
||
dusk: {
|
||
light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" },
|
||
dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" },
|
||
},
|
||
sunset: {
|
||
light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" },
|
||
dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" },
|
||
},
|
||
twilight: {
|
||
light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" },
|
||
dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" },
|
||
},
|
||
forest: {
|
||
light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" },
|
||
dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" },
|
||
},
|
||
moss: {
|
||
light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" },
|
||
dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" },
|
||
},
|
||
slate: {
|
||
light: { fg: "#334155", bg: "#f8fafc", link: "#475569" },
|
||
dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" },
|
||
},
|
||
oled: {
|
||
light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" },
|
||
dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" },
|
||
},
|
||
solarized: {
|
||
light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" },
|
||
dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" },
|
||
},
|
||
};
|
||
|
||
const getCSS = ({
|
||
fontFamily,
|
||
fontSize,
|
||
lineHeight,
|
||
justify,
|
||
hyphenate,
|
||
themeName,
|
||
themeMode,
|
||
}: {
|
||
fontFamily: string;
|
||
fontSize: number;
|
||
lineHeight: number;
|
||
justify: boolean;
|
||
hyphenate: boolean;
|
||
themeName: string;
|
||
themeMode: string;
|
||
}) => {
|
||
const themeSet = THEME_COLORS[themeName] ?? THEME_COLORS.light;
|
||
const theme = themeMode === "dark" ? themeSet.dark : themeSet.light;
|
||
const font = FONT_MAP[fontFamily] ?? '"Literata"';
|
||
const fontFamilyRule = fontFamily
|
||
? `
|
||
body {
|
||
font-family: ${font}, serif !important;
|
||
}
|
||
body * {
|
||
font-family: inherit !important;
|
||
}`
|
||
: "";
|
||
|
||
return `
|
||
${buildFontFaceCSS()}
|
||
@namespace epub "http://www.idpf.org/2007/ops";
|
||
html {
|
||
color-scheme: light dark;
|
||
font-size: ${fontSize}px;
|
||
line-height: ${lineHeight};
|
||
}
|
||
${fontFamilyRule}
|
||
html, body {
|
||
color: ${theme.fg} !important;
|
||
background-color: ${theme.bg} !important;
|
||
}
|
||
body * {
|
||
color: inherit !important;
|
||
border-color: currentColor !important;
|
||
}
|
||
a:any-link {
|
||
color: ${theme.link} !important;
|
||
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
|
||
text-underline-offset: .1em;
|
||
}
|
||
svg, img {
|
||
background-color: transparent !important;
|
||
}
|
||
aside[epub|type~="footnote"] {
|
||
display: none;
|
||
}
|
||
p, li, blockquote, dd {
|
||
line-height: ${lineHeight} !important;
|
||
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; }
|
||
[align="justify"] { text-align: justify; }
|
||
pre {
|
||
white-space: pre-wrap !important;
|
||
tab-size: 2;
|
||
}
|
||
::selection {
|
||
background-color: rgba(128, 128, 128, 0.3);
|
||
}
|
||
img, svg, video {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
html {
|
||
hanging-punctuation: allow-end last;
|
||
orphans: 2;
|
||
widows: 2;
|
||
}
|
||
`;
|
||
};
|
||
document.addEventListener("alpine:init", () => {
|
||
Alpine.data("readerShell", () => ({
|
||
view: null as any,
|
||
renderer: null as any,
|
||
book: null as any,
|
||
zoomPercent: 100,
|
||
isFixedLayout: false,
|
||
isPDF: false,
|
||
isComic: false,
|
||
// Fixed-layout item in a comics/manga library (e.g. a manga PDF):
|
||
// comic reader treatment — bookmarks only, no text-selection
|
||
// highlights, no in-book search.
|
||
treatAsComic: false as boolean,
|
||
// Screen wake lock (keep_screen_awake profile preference): held for
|
||
// the whole reading session; the browser force-releases it when the
|
||
// page hides and the manager re-acquires on visibility regain.
|
||
wakeLock: new WakeLockManager(),
|
||
comicFlow: "paged" as string,
|
||
fxBrightness: 1 as number,
|
||
fxContrast: 1 as number,
|
||
fxInvert: false as boolean,
|
||
interactionMode: "select" as string,
|
||
magnifierEnabled: false,
|
||
doublePageSpread: true as boolean,
|
||
chromeVisible: true,
|
||
chromeBehavior: "auto-hide" as string,
|
||
hideTimer: null as ReturnType<typeof setTimeout> | null,
|
||
toolsOpen: false,
|
||
helpOpen: false,
|
||
// Fullscreen mode — see toggleFullscreen(). Tracks both the real
|
||
// Fullscreen-API surface (desktop/Android) and the soft visual mode
|
||
// iOS falls back to (its browser exposes no page-fullscreen API).
|
||
isFullscreen: false as boolean,
|
||
softFullscreen: false as boolean,
|
||
_hadRealFullscreen: false as boolean,
|
||
pointerFine: true as boolean,
|
||
fxZoomed: false as boolean,
|
||
tapZonesEnabled: true as boolean,
|
||
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;
|
||
note: string;
|
||
noteHtml: string;
|
||
color: string;
|
||
cfi: string;
|
||
cfiEnd: string;
|
||
renderCfi: string;
|
||
percentage: number;
|
||
pdfPage: number;
|
||
pdfRects: number[][];
|
||
}[],
|
||
noteItems: [] as { id: string; content: string; contentHtml: string; positionLabel: string }[],
|
||
annotationsTab: "highlights" as string,
|
||
newNoteText: "",
|
||
// Mirrors the server-side validator cap on note content
|
||
// (internal/handlers/media.go). Enforced client-side so oversized
|
||
// input is caught before a doomed POST.
|
||
noteMaxLength: 100000 as number,
|
||
highlightColors: HIGHLIGHT_COLORS,
|
||
searchOpen: false,
|
||
searchQuery: "",
|
||
searchGroups: [] as {
|
||
label: string;
|
||
items: { cfi: string; pre: string; match: string; post: string }[];
|
||
}[],
|
||
searchProgress: 0,
|
||
searching: false,
|
||
searchError: "",
|
||
searchGen: 0,
|
||
pdfPagesCache: null as PdfPageText[] | null,
|
||
tocTab: "contents" as string,
|
||
pageThumbList: [] as { index: number }[],
|
||
thumbObserver: null as IntersectionObserver | null,
|
||
pdfSearchKeys: [] as string[],
|
||
backStack: [] as { cfi?: string; page?: number }[],
|
||
selectionPopover: {
|
||
open: false,
|
||
mode: "create" as "create" | "edit",
|
||
x: 0,
|
||
y: 0,
|
||
text: "",
|
||
cfi: "",
|
||
cfiEnd: "",
|
||
id: "",
|
||
color: "#ffd54f",
|
||
note: "",
|
||
noteOpen: false,
|
||
// PDF/fixed-layout anchor: page index + page-fraction rects
|
||
// (empty for CFI-anchored reflowable highlights).
|
||
pdfPage: -1,
|
||
pdfRects: [] as number[][],
|
||
},
|
||
/** Extent-adjustment context (objective 9): stashed by show-annotation,
|
||
* consumed by startAdjust. Null outside adjust mode. */
|
||
adjustContext: null as null | {
|
||
id: string;
|
||
index: number;
|
||
doc: any;
|
||
range: Range;
|
||
color: string;
|
||
},
|
||
adjustCancelOnRelocate: null as null | (() => void),
|
||
/** PDF rect-edge adjust session (fixed-layout highlights): the working
|
||
* copy of the highlight's rects (page fractions) plus the original
|
||
* for cancel. Null outside adjust mode. */
|
||
pdfAdjust: null as null | {
|
||
id: string;
|
||
index: number;
|
||
page: number;
|
||
color: string;
|
||
rects: number[][];
|
||
original: number[][];
|
||
/** The working extent as a live DOM range: handles re-anchor its
|
||
* boundaries via caret probes, and rects rebuild from its line
|
||
* boxes on every move. */
|
||
range: Range;
|
||
/** True once finalize handed off to saveHighlightChanges — cancel
|
||
* must not restore the original paint over the saved new one. */
|
||
committed: boolean;
|
||
},
|
||
pdfAdjustRaf: 0,
|
||
pdfAdjustOnRelocate: null as null | (() => void),
|
||
pdfAdjustOnZoom: null as null | (() => void),
|
||
pdfSelDoc: null as any,
|
||
progressText: "",
|
||
progressLabel: "",
|
||
progressMain: "",
|
||
sliderValue: 0,
|
||
settings: null as ReaderSettings | null,
|
||
justify: true,
|
||
hyphenate: true,
|
||
tocOpen: false,
|
||
settingsOpen: false,
|
||
bookmarksOpen: false,
|
||
bookmarkItems: [] as {
|
||
id: string;
|
||
title: string;
|
||
positionLabel: string;
|
||
cfi: string;
|
||
page: number | null;
|
||
renameOpen?: boolean;
|
||
renameText?: string;
|
||
}[],
|
||
tocItems: [] as any[],
|
||
mediaItemId: "" as string,
|
||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||
// Position last known to be stored (the restore at open time, or the
|
||
// last successful save). Relocations that don't move from it are never
|
||
// written back, so a restored position can't clobber a newer device
|
||
// push — while swipe/scroll paging (handled inside foliate, with no
|
||
// wrapper method to flag) still saves normally.
|
||
lastSyncedCfi: "" as string,
|
||
lastSyncedFraction: -1 as number,
|
||
lastCfi: "" as string,
|
||
contextText: "" as string,
|
||
readingTheme: "light" as string,
|
||
readingMode: "light" as string,
|
||
readingFont: "literata" as string,
|
||
fontSize: 16 as number,
|
||
lineHeight: 1.6 as number,
|
||
progressMode: "pages" as string,
|
||
readingSpeedPpm: 0 as number,
|
||
sectionFractionsArr: [] as number[],
|
||
lastRelocateDetail: null as {
|
||
fraction: number;
|
||
location: { current: number; next: number; total: number };
|
||
pageItem: { id: number; label: string; href: string } | null;
|
||
tocItem: FoliateTocItem | null;
|
||
section: { current: number; total: number };
|
||
} | null,
|
||
chapterBoundaries: [] as {
|
||
id: number;
|
||
label: string;
|
||
startPage: number;
|
||
}[],
|
||
get themeSwatches(): {
|
||
value: string;
|
||
label: string;
|
||
bg: string;
|
||
fg: string;
|
||
}[] {
|
||
const dark = this.readingMode === "dark";
|
||
return Object.entries(THEME_COLORS).map(([value, set]) => {
|
||
const colors = dark ? set.dark : set.light;
|
||
return {
|
||
value,
|
||
label: value.charAt(0).toUpperCase() + value.slice(1),
|
||
bg: colors.bg,
|
||
fg: colors.fg,
|
||
};
|
||
});
|
||
},
|
||
get searchMatchCount(): number {
|
||
return this.searchGroups.reduce(
|
||
(n: number, g: { items: unknown[] }) => n + g.items.length,
|
||
0,
|
||
);
|
||
},
|
||
init() {
|
||
this.pointerFine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||
const link = document.getElementById("reader-back");
|
||
if (!link) return;
|
||
const storageKey = "reader_back";
|
||
if (document.referrer) {
|
||
try {
|
||
const ref = new URL(document.referrer);
|
||
if (
|
||
ref.origin === window.location.origin &&
|
||
!ref.pathname.startsWith("/readers/") &&
|
||
ref.pathname !== window.location.pathname
|
||
) {
|
||
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
|
||
}
|
||
} catch {}
|
||
} else {
|
||
sessionStorage.removeItem(storageKey);
|
||
}
|
||
const backUrl =
|
||
sessionStorage.getItem(storageKey) || link.getAttribute("href") || "/dashboard";
|
||
link.addEventListener("click", (e: Event) => {
|
||
e.preventDefault();
|
||
sessionStorage.removeItem(storageKey);
|
||
window.location.href = backUrl;
|
||
});
|
||
},
|
||
async initReader(config: {
|
||
mediaItemId: string;
|
||
fileUrl: string;
|
||
formatGroup: string;
|
||
readingDirection: string;
|
||
mangaType: string;
|
||
treatAsComic: boolean;
|
||
keepScreenAwake: boolean;
|
||
}) {
|
||
this.mediaItemId = config.mediaItemId;
|
||
this.treatAsComic = config.treatAsComic;
|
||
// Hold the display awake for the reading session (best-effort;
|
||
// unsupported browsers and power-saver denials are silent no-ops).
|
||
if (config.keepScreenAwake) void this.wakeLock.acquire();
|
||
// Reading state (position, bookmarks, annotations) is never baked
|
||
// into the rendered page: the web reader is intrinsically tied to
|
||
// the server, so it reads all of it from the APIs at open time —
|
||
// a device sync between render and open can never be shadowed by a
|
||
// stale snapshot.
|
||
this.isComic = config.formatGroup === "comic_archive";
|
||
// Reading flow for comics is a per-book preference (a webtoon title
|
||
// vs. a paged manga volume); read before the renderer is chosen.
|
||
this.comicFlow =
|
||
localStorage.getItem(`bookhoard:comic-flow:${config.mediaItemId}`) === "webtoon"
|
||
? "webtoon"
|
||
: "paged";
|
||
this.settings = await loadSettings();
|
||
if (this.settings) {
|
||
this.progressMode = this.settings.progress_mode || "pages";
|
||
this.readingTheme = this.settings.reading_theme || "light";
|
||
this.readingFont = this.settings.reading_font || "literata";
|
||
this.fontSize = this.settings.font_size || 18;
|
||
this.lineHeight = this.settings.line_height || 1.6;
|
||
this.doublePageSpread = this.settings.double_page_spread ?? true;
|
||
const behavior = this.settings.chrome_behavior || "auto-hide";
|
||
this.chromeBehavior = behavior === "always-visible" ? "always-visible" : "auto-hide";
|
||
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
|
||
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;
|
||
}
|
||
this.fxBrightness = this.settings.fx_brightness || 1;
|
||
this.fxContrast = this.settings.fx_contrast || 1;
|
||
this.fxInvert = this.settings.fx_invert ?? false;
|
||
if (this.settings.reading_mode) {
|
||
this.readingMode = this.settings.reading_mode;
|
||
} else {
|
||
this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
|
||
}
|
||
}
|
||
const viewport = document.getElementById("reader-viewport")!;
|
||
const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
|
||
const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light;
|
||
viewport.style.backgroundColor = themeColors.bg;
|
||
this.applyFxFilter();
|
||
this.view = document.getElementById("reader-view") as any;
|
||
await loadFontBlobUrls();
|
||
const resp = await fetch(config.fileUrl, {
|
||
headers: { Authorization: `Bearer ${getToken()}` },
|
||
});
|
||
const blob = await resp.blob();
|
||
const fileName = new URL(config.fileUrl, window.location.origin).pathname;
|
||
const file = new File([blob], fileName, { type: blob.type });
|
||
await this.view.open(file, {
|
||
pdf: {},
|
||
comic: { flow: this.comicFlow },
|
||
});
|
||
this.renderer = this.view.renderer;
|
||
this.book = this.view.book;
|
||
// Populate the TOC eagerly (PDF outlines and EPUB TOCs alike) so the
|
||
// drawer never depends on lazy-toggle timing; toggleTOC only opens.
|
||
if (this.book?.toc?.length) {
|
||
this.tocItems = this.flattenTOC(this.book.toc);
|
||
}
|
||
this.isFixedLayout = this.view.isFixedLayout;
|
||
if (this.isFixedLayout) {
|
||
// Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
|
||
// but makeComicBook never sets dir. Apply it from metadata so RTL page
|
||
// turns (and spread ordering) read right-to-left. Reflowable EPUBs keep
|
||
// whatever direction foliate read from the OPF.
|
||
if (config.readingDirection === "rtl" && this.book.dir !== "rtl") {
|
||
this.book.dir = "rtl";
|
||
}
|
||
this.isPDF = (this.renderer as any).isPDF;
|
||
// Click on a PDF/fixed-layout highlight rect → edit popover.
|
||
this.renderer.addEventListener("show-rect-annotation", (e: any) => {
|
||
const { key, clientX, clientY } = e.detail;
|
||
// A tap anywhere on the page during rect adjust must not pop
|
||
// the editor mid-session (page turn is the cancel gesture).
|
||
if (this.pdfAdjust) return;
|
||
const h = this.highlightItems.find((x: any) => x.id === key);
|
||
if (!h) return;
|
||
this.openSelectionPopover({
|
||
mode: "edit",
|
||
x: clientX,
|
||
y: clientY,
|
||
text: h.text,
|
||
cfi: "",
|
||
id: h.id,
|
||
color: h.color,
|
||
note: h.note,
|
||
pdfPage: h.pdfPage,
|
||
pdfRects: h.pdfRects,
|
||
});
|
||
});
|
||
this.renderer.addEventListener("zoom", () => {
|
||
this.zoomPercent = this.renderer.zoomPercent;
|
||
this.fxZoomed = this.renderer.zoom != null;
|
||
});
|
||
this.computeFixedLayoutChapterBoundaries();
|
||
this.applyDoublePageSpread();
|
||
} else {
|
||
this.renderer.setStyles?.(this.buildCSS());
|
||
}
|
||
this.view.addEventListener("load", (e: any) => {
|
||
const { doc, index } = e.detail;
|
||
if (doc && typeof index === "number") pdfFrameDocs.set(doc, index);
|
||
this.currentDoc = doc;
|
||
const link = doc.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = "/static/reader-fonts.css";
|
||
doc.head.append(link);
|
||
doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev));
|
||
// Edge bands work inside page documents too — screenY keeps the
|
||
// check viewport-relative even when the iframe is content-sized
|
||
// (fixed-layout pages and EPUB sections are separate documents).
|
||
doc.addEventListener(
|
||
"pointermove",
|
||
(e: PointerEvent) => {
|
||
if (e.pointerType !== "mouse") return;
|
||
const vy = e.screenY - window.screenY;
|
||
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
|
||
this.pokeChrome(1200);
|
||
},
|
||
{
|
||
passive: true,
|
||
},
|
||
);
|
||
// Tap zones inside the page document (iframe events never bubble
|
||
// out to the host document, so the viewport listeners miss them).
|
||
if (window.matchMedia("(pointer: coarse)").matches) {
|
||
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
||
// Same for selectionchange: a selection inside the iframe must
|
||
// cancel armed tap actions and feed the host-surface guard.
|
||
doc.addEventListener("selectionchange", () => {
|
||
const sel = doc.getSelection();
|
||
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.treatAsComic && 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 = (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;
|
||
}
|
||
this.openPopoverForSelection(sel, doc, index);
|
||
};
|
||
doc.addEventListener(
|
||
"pointerup",
|
||
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
||
{ passive: true },
|
||
);
|
||
doc.addEventListener(
|
||
"keyup",
|
||
(ev: KeyboardEvent) => {
|
||
if (ev.shiftKey) setTimeout(checkSelection, 0);
|
||
},
|
||
{ passive: true },
|
||
);
|
||
}
|
||
// PDF textLayer selection → rect-fraction highlight popover.
|
||
// Detected structurally: renderer.isPDF isn't set until the first
|
||
// spread renders (during view.init), which is after this listener
|
||
// attaches — the stale copy here would always be falsy.
|
||
// Comic-treated PDFs never attach it: no highlights, like comics.
|
||
if (this.isFixedLayout && !this.treatAsComic && doc.querySelector(".textLayer")) {
|
||
doc.addEventListener(
|
||
"pointerup",
|
||
(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 },
|
||
);
|
||
}
|
||
if (!this.isFixedLayout) {
|
||
this.computeChapterPageBoundaries(doc);
|
||
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
||
}
|
||
});
|
||
// ----- highlight rendering (foliate overlayer pipeline) -----
|
||
// Internal link clicks (footnotes, cross-references): record where we
|
||
// came from so the back-to-location stack can return; foliate then
|
||
// navigates on its own.
|
||
this.view.addEventListener("link", () => this.pushBackStack());
|
||
this.view.addEventListener("draw-annotation", (e: any) => {
|
||
const { draw, annotation } = e.detail;
|
||
draw(Overlayer.highlight, { color: annotation.color || "#ffd54f" });
|
||
});
|
||
this.view.addEventListener("show-annotation", (e: any) => {
|
||
const { value, index, range } = e.detail;
|
||
// Device-synced highlights are painted with a synthesized range
|
||
// CFI (renderCfi) while the stored locator stays a point CFI, so a
|
||
// click reports the render value — match either or editing
|
||
// device highlights is impossible.
|
||
const h = this.highlightItems.find((x) => x.cfi === value || x.renderCfi === value);
|
||
if (!h) return;
|
||
const doc = this.renderer?.getContents?.()?.find((c: any) => c.index === index)?.doc;
|
||
const frame = doc?.defaultView?.frameElement as HTMLElement | null;
|
||
const iframeRect = frame?.getBoundingClientRect();
|
||
const rect = range.getBoundingClientRect();
|
||
// Stash the live range for extent adjustment (objective 9): the
|
||
// overlayer hands back the exact DOM range it painted from.
|
||
this.adjustContext = { id: h.id, index, doc, range, color: h.color };
|
||
this.openSelectionPopover({
|
||
mode: "edit",
|
||
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
|
||
y: (iframeRect?.top ?? 0) + rect.top,
|
||
text: h.text,
|
||
cfi: h.cfi,
|
||
cfiEnd: h.cfiEnd,
|
||
id: h.id,
|
||
color: h.color,
|
||
note: h.note,
|
||
});
|
||
});
|
||
this.view.addEventListener("create-overlay", () => {
|
||
this.renderAllHighlights();
|
||
});
|
||
this.view.addEventListener("relocate", (e: any) => {
|
||
// A touch swipe page turn ends in a snap-relocate: reading
|
||
// intent — dismiss the chrome (guards keep drawers, selections,
|
||
// and pinned mode up).
|
||
if (e.detail?.reason === "snap") this.hideChromeNow();
|
||
const { fraction, location, pageItem, cfi, tocItem, section } = e.detail;
|
||
// 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,
|
||
location,
|
||
pageItem,
|
||
tocItem,
|
||
section,
|
||
};
|
||
const progressParts = this.formatProgressParts(
|
||
fraction,
|
||
location,
|
||
pageItem,
|
||
tocItem,
|
||
section,
|
||
);
|
||
this.setProgress(progressParts);
|
||
this.sliderValue = fraction;
|
||
for (const slider of this.progressSliders()) {
|
||
slider.value = fraction;
|
||
}
|
||
const range = e.detail.range as Range | undefined;
|
||
if (range) {
|
||
const text = range.toString().replace(/\s+/g, " ").trim();
|
||
this.contextText = text ? text.slice(0, 100) : "";
|
||
}
|
||
this.debouncedSaveProgress(fraction, location, cfi);
|
||
});
|
||
if (this.book.dir) {
|
||
for (const slider of this.progressSliders()) {
|
||
slider.dir = this.book.dir;
|
||
}
|
||
}
|
||
if (this.view.getSectionFractions) {
|
||
this.sectionFractionsArr = this.view.getSectionFractions();
|
||
const tickMarks = document.getElementById("tick-marks");
|
||
if (tickMarks) {
|
||
for (const fraction of this.sectionFractionsArr) {
|
||
const option = document.createElement("option");
|
||
option.value = fraction;
|
||
tickMarks.append(option);
|
||
}
|
||
}
|
||
}
|
||
document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev));
|
||
// Reading position comes from the database, fetched fresh at open
|
||
// (the rendered page carries no snapshot of it) — UNLESS an
|
||
// annotations-banner jump handed one in via ?loc= (objective 7:
|
||
// the jump outranks the stored progress for this open only).
|
||
const savedLoc = await this.fetchSavedLocation();
|
||
const jump = this.consumeLocJump();
|
||
const saved = jump ?? savedLoc;
|
||
if (this.isFixedLayout && jump?.page != null) {
|
||
await this.view.init({ lastLocation: jump.page - 1 });
|
||
} else if (this.isFixedLayout && saved.page != null && saved.page > 0) {
|
||
// Fixed-layout & comics: a page index is the exact, universal locator.
|
||
// A bare number navigates directly to the section index in foliate.
|
||
await this.view.init({ lastLocation: saved.page - 1 });
|
||
} else if (jump?.cfi) {
|
||
await this.view.init({ lastLocation: jump.cfi });
|
||
} else if (saved.cfi) {
|
||
await this.view.init({ lastLocation: saved.cfi });
|
||
} else if (saved.percentage != null && saved.percentage > 0) {
|
||
await this.view.init({
|
||
lastLocation: { fraction: saved.percentage },
|
||
});
|
||
} else {
|
||
await this.view.init({});
|
||
}
|
||
// The position restored above (or the start of the book on a fresh
|
||
// open) is the baseline: only relocations that actually move from
|
||
// it may write progress.
|
||
this.lastSyncedCfi = this.lastCfi;
|
||
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||
// The renderer only knows it's a PDF once frames exist (they carry
|
||
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
||
// Read it now and apply the saved pointer mode — this also makes the
|
||
// Smart|Pan|Text control appear for PDFs.
|
||
this.isPDF = !!this.renderer?.isPDF;
|
||
if (this.isPDF) {
|
||
// Comic-treated PDFs are paged through like comics: no text
|
||
// selection, so pointer interactions stay in pan mode.
|
||
if (this.treatAsComic) this.interactionMode = "pan";
|
||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||
}
|
||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||
// A bfcache-resurrected page is stale by definition: re-baseline to
|
||
// its frozen position so it can't write that back until the user
|
||
// actually navigates again.
|
||
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||
if (e.persisted) {
|
||
this.lastSyncedCfi = this.lastCfi;
|
||
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||
}
|
||
});
|
||
this.fetchReadingSpeed();
|
||
this.refreshAnnotations();
|
||
this.refreshBookmarks();
|
||
this.setupChrome();
|
||
this.setupTapZones();
|
||
this.setupFullscreen();
|
||
},
|
||
// Chrome visibility: bars overlay the edge-to-edge reading surface.
|
||
// 'auto-hide' reveals them ONLY from explicit UI intent — cursor in
|
||
// the top/bottom edge bands (desktop) or the flick-down gesture
|
||
// (touch) — and every reading action (page turns, selections,
|
||
// highlight saves) dismisses or never raises them. 'always-visible'
|
||
// pins them.
|
||
setupChrome() {
|
||
// Band check via screenY so it works identically for the host
|
||
// document and content iframes (iframe clientY is content-space,
|
||
// which is unrelated to the viewport in scrolled layouts).
|
||
document.addEventListener(
|
||
"pointermove",
|
||
(e: PointerEvent) => {
|
||
if (e.pointerType !== "mouse") return;
|
||
const vy = e.screenY - window.screenY;
|
||
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
|
||
this.pokeChrome(1200);
|
||
},
|
||
{ passive: true },
|
||
);
|
||
// Dismiss the selection popover and tools menu on clicks outside them
|
||
// (iframe clicks are covered by the selection tracker's collapsed
|
||
// check; the ⋯ toggle is exempt so its own click re-toggles cleanly).
|
||
document.addEventListener(
|
||
"pointerdown",
|
||
(e: PointerEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
if (this.selectionPopover.open && !target?.closest?.("#selection-popover")) {
|
||
this.hideSelectionPopover();
|
||
}
|
||
if (this.toolsOpen && !target?.closest?.(".reader-tools-popover, [data-tools-toggle]")) {
|
||
this.toolsOpen = false;
|
||
}
|
||
},
|
||
{ passive: true },
|
||
);
|
||
if (this.chromeBehavior === "always-visible") {
|
||
this.chromeVisible = true;
|
||
return;
|
||
}
|
||
// Chrome starts hidden: reveal is an explicit gesture (edge band /
|
||
// flick down), never a side effect of loading.
|
||
},
|
||
pokeChrome(hideAfterMs = 2500) {
|
||
// 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);
|
||
this.hideTimer = setTimeout(() => {
|
||
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
|
||
this.chromeVisible = false;
|
||
}, hideAfterMs);
|
||
},
|
||
// Reading intent: dismiss the chrome immediately. Respects pinned
|
||
// mode and anything that owns the screen.
|
||
hideChromeNow() {
|
||
if (this.chromeBehavior !== "auto-hide") return;
|
||
if (this.anyDrawerOpen() || this.anySelection) return;
|
||
this.chromeVisible = false;
|
||
if (this.hideTimer) {
|
||
clearTimeout(this.hideTimer);
|
||
this.hideTimer = null;
|
||
}
|
||
},
|
||
chromeHasFocus(): boolean {
|
||
const el = document.activeElement;
|
||
if (!el) return false;
|
||
const tag = el.tagName;
|
||
return (
|
||
(tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") &&
|
||
!!el.closest("#reader-topbar, #reader-bottombar")
|
||
);
|
||
},
|
||
applyChromeBehavior() {
|
||
if (this.chromeBehavior !== "auto-hide") this.chromeVisible = true;
|
||
else this.pokeChrome();
|
||
saveSettings({ chrome_behavior: this.chromeBehavior });
|
||
},
|
||
// ----- tap zones (touch devices) -----
|
||
// Kindle-style: tap left/right margins to page, center to toggle chrome.
|
||
// Pointer-based and passive, so drags (pan/selection/swipe) never
|
||
// trigger. Attached to the viewport and inside every page document
|
||
// (iframe contents don't bubble to the host document).
|
||
setupTapZones() {
|
||
if (!window.matchMedia("(pointer: coarse)").matches) return;
|
||
const vp = document.getElementById("reader-viewport");
|
||
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
|
||
// Host-document selections (fixed-layout/PDF text layers, margins):
|
||
// selectionchange never crosses iframe boundaries, so register per
|
||
// surface.
|
||
document.addEventListener("selectionchange", () => {
|
||
const sel = document.getSelection();
|
||
this.noteSelectionActivity(!!sel && !sel.isCollapsed && !!sel.toString());
|
||
});
|
||
},
|
||
// Record selection state and abort any armed tap action: a selection
|
||
// appearing right after finger-lift means the "tap" was actually a
|
||
// long-press selection engaging, and paging away would destroy the
|
||
// gesture the user just made.
|
||
noteSelectionActivity(hasSelection: boolean) {
|
||
this.anySelection = hasSelection;
|
||
if (hasSelection && this.tapZoneTimer) {
|
||
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;
|
||
let downY = 0;
|
||
let downT = 0;
|
||
let downId = -1;
|
||
let moved = false;
|
||
// Android fires contextmenu when a long-press engages text
|
||
// selection: that press must never resolve into a tap action, even
|
||
// when it was released inside the 500ms tap window (the selection
|
||
// engaging and the guard racing is exactly how corner selections
|
||
// used to page back instead).
|
||
let longPressed = false;
|
||
surface.addEventListener(
|
||
"pointerdown",
|
||
(e: PointerEvent) => {
|
||
if (!e.isPrimary || e.pointerType === "mouse") return;
|
||
downX = e.clientX;
|
||
downY = e.clientY;
|
||
downT = Date.now();
|
||
downId = e.pointerId;
|
||
moved = false;
|
||
longPressed = false;
|
||
this.lastTouchDownT = downT;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
surface.addEventListener(
|
||
"contextmenu",
|
||
() => {
|
||
longPressed = true;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
// The browser takes over the gesture (text selection, scroll) with
|
||
// pointercancel — no pointerup will follow. Drop the tracked
|
||
// pointer so stale state can never match a later touch.
|
||
surface.addEventListener(
|
||
"pointercancel",
|
||
() => {
|
||
downId = -1;
|
||
moved = false;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
surface.addEventListener(
|
||
"pointermove",
|
||
(e: PointerEvent) => {
|
||
if (e.pointerId !== downId) return;
|
||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12) moved = true;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
surface.addEventListener(
|
||
"pointerup",
|
||
(e: PointerEvent) => {
|
||
if (e.pointerId !== downId) return;
|
||
downId = -1;
|
||
// Flick-down/up chrome gesture (touch only): vertical-dominant
|
||
// and fast. Down reveals a hidden chrome; while visible, down
|
||
// OR up dismisses (up reads as "push it away"). Reading
|
||
// gestures never look like this: side swipes (page turns) are
|
||
// horizontal, long-press selection engages slowly, and zoomed
|
||
// pages already bail out of tap routing.
|
||
const dy = e.clientY - downY;
|
||
const dx = e.clientX - downX;
|
||
const dt = Date.now() - downT;
|
||
if (
|
||
e.pointerType !== "mouse" &&
|
||
!this.anySelection &&
|
||
dt < 400 &&
|
||
Math.abs(dy) > 48 &&
|
||
Math.abs(dy) > Math.abs(dx) * 1.8
|
||
) {
|
||
if (this.tapZoneTimer) {
|
||
clearTimeout(this.tapZoneTimer);
|
||
this.tapZoneTimer = null;
|
||
}
|
||
if (!this.chromeVisible) this.pokeChrome();
|
||
else this.hideChromeNow();
|
||
return;
|
||
}
|
||
if (moved || longPressed || dt > 500) return;
|
||
if (!this.tapZonesEnabled) return;
|
||
const target = e.target as HTMLElement | null;
|
||
if (target?.closest?.("a, button, input, textarea, select, [contenteditable]")) return;
|
||
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
||
if (sel?.toString?.()) return;
|
||
// Host-surface blind spot: selections living in content iframes
|
||
// (or the host's own fixed-layout text layer) never show in a
|
||
// per-surface check — the tracked flag covers them.
|
||
if (!isDoc && this.anySelection) return;
|
||
// 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;
|
||
// 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 },
|
||
);
|
||
},
|
||
// A short debounce lets a second tap (double-tap zoom in the fixed-layout
|
||
// engine) cancel the zone action instead of also paging.
|
||
routeTapDebounced(relX: number) {
|
||
if (this.tapZoneTimer) {
|
||
clearTimeout(this.tapZoneTimer);
|
||
this.tapZoneTimer = null;
|
||
return;
|
||
}
|
||
this.tapZoneTimer = setTimeout(() => {
|
||
this.tapZoneTimer = null;
|
||
// Touch: taps are fully inert — paging is swipe-only and the
|
||
// chrome is flick-down/up territory (handoff item 7). A stray
|
||
// tap must never flash the UI.
|
||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||
const zone = this.tapZoneSize / 100;
|
||
if (relX < zone) this.goLeft();
|
||
else if (relX > 1 - zone) this.goRight();
|
||
// Mouse taps in the content are content (links, selection) —
|
||
// they never raise the chrome; the edge bands own that.
|
||
}, 280);
|
||
},
|
||
applyTapZoneSettings() {
|
||
saveSettings({
|
||
tap_zones_enabled: this.tapZonesEnabled,
|
||
tap_zone_size: this.tapZoneSize,
|
||
});
|
||
},
|
||
// ----- annotations (highlights + notes) -----
|
||
openSelectionPopover(opts: {
|
||
mode: "create" | "edit";
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
cfi: string;
|
||
cfiEnd?: string;
|
||
id?: string;
|
||
color?: string;
|
||
note?: string;
|
||
pdfPage?: number;
|
||
pdfRects?: number[][];
|
||
}) {
|
||
const p = this.selectionPopover;
|
||
p.mode = opts.mode;
|
||
p.text = opts.text;
|
||
p.cfi = opts.cfi;
|
||
p.cfiEnd = opts.cfiEnd ?? "";
|
||
p.id = opts.id ?? "";
|
||
p.color = opts.color || "#ffd54f";
|
||
p.note = opts.note ?? "";
|
||
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 and
|
||
// renders ABOVE the anchor: top = y - 10 - height). The size is
|
||
// estimated up front — the note area is already open when editing an
|
||
// existing note — and the position MUST be final before p.open = true
|
||
// (mutating mid-enter-transition breaks compositor hit-testing).
|
||
const w = window.innerWidth;
|
||
const h = window.innerHeight;
|
||
const estW = 240;
|
||
const estH = p.noteOpen ? 300 : 130;
|
||
p.x = Math.min(Math.max(opts.x, estW / 2 + 8), w - estW / 2 - 8);
|
||
p.y = Math.min(Math.max(opts.y, estH + 18), h - 60);
|
||
p.open = true;
|
||
},
|
||
// Note area opens after the popover is visible; the textarea widens the
|
||
// popover, so measure the real box after render and shift it back inside
|
||
// the window (the popover's own enter transition has finished by the
|
||
// time a user clicks Note, so adjusting here is safe).
|
||
openSelectionNote() {
|
||
const p = this.selectionPopover;
|
||
p.noteOpen = true;
|
||
const w = window.innerWidth;
|
||
const h = window.innerHeight;
|
||
p.x = Math.min(Math.max(p.x, 148), w - 148);
|
||
p.y = Math.min(Math.max(p.y, 318), h - 60);
|
||
this.$nextTick(() => {
|
||
const el = document.querySelector(".reader-popover") as HTMLElement | null;
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
if (r.x < 8) p.x += 8 - r.x;
|
||
if (r.right > w - 8) p.x -= r.right - (w - 8);
|
||
if (r.y < 8) p.y += 8 - r.y;
|
||
if (r.bottom > h - 8) p.y -= r.bottom - (h - 8);
|
||
});
|
||
},
|
||
// Highlight color picker (CSP-safe: single method call — the previous
|
||
// inline nested-ternary + comma-sequence expression is unparseable by
|
||
// the CSP build's evaluator).
|
||
pickHighlightColor(c: string) {
|
||
const p = this.selectionPopover;
|
||
p.color = c;
|
||
if (p.noteOpen) {
|
||
if (p.mode !== "create") this.saveHighlightChanges(true);
|
||
} else if (p.mode === "create") {
|
||
this.createHighlight(c);
|
||
} else {
|
||
this.saveHighlightChanges();
|
||
}
|
||
},
|
||
hideSelectionPopover() {
|
||
this.selectionPopover.open = false;
|
||
this.selectionPopover.noteOpen = false;
|
||
},
|
||
// ----- highlight extent adjustment (objective 9) -----
|
||
/** Enter adjust mode: hide the popover, render start/end handles over
|
||
* the clicked highlight's range, and take over drags. The range is
|
||
* edited live with an overlayer preview; release recomputes the CFIs
|
||
* and saves through the same path the popover's Save uses. */
|
||
startAdjust() {
|
||
const ctx = this.adjustContext;
|
||
if (!ctx || !ctx.range) return;
|
||
this.hideSelectionPopover();
|
||
const layer = this.ensureAdjustLayer();
|
||
layer.innerHTML = "";
|
||
const mkHandle = (which: "start" | "end") => {
|
||
const dot = document.createElement("div");
|
||
dot.className = "adjust-handle";
|
||
dot.dataset.which = which;
|
||
// Pointer events (mouse + touch + pen); the page's own touch
|
||
// handlers are not engaged because the handles live in the HOST
|
||
// document, above the iframe.
|
||
dot.addEventListener("pointerdown", (e) => {
|
||
e.preventDefault();
|
||
dot.setPointerCapture(e.pointerId);
|
||
dot.dataset.dragging = "1";
|
||
});
|
||
dot.addEventListener("pointermove", (e) => {
|
||
if (!dot.dataset.dragging) return;
|
||
this.adjustMove(which, e.clientX, e.clientY);
|
||
});
|
||
dot.addEventListener("pointerup", (e) => {
|
||
if (!dot.dataset.dragging) return;
|
||
delete dot.dataset.dragging;
|
||
dot.releasePointerCapture?.(e.pointerId);
|
||
this.adjustFinalize();
|
||
});
|
||
dot.addEventListener("pointercancel", () => {
|
||
delete dot.dataset.dragging;
|
||
});
|
||
layer.appendChild(dot);
|
||
return dot;
|
||
};
|
||
mkHandle("start");
|
||
mkHandle("end");
|
||
this.positionAdjustHandles();
|
||
// A page turn (or any relocation that re-renders the section) ends
|
||
// the adjustment safely — the preview is discarded, nothing saved.
|
||
this.adjustCancelOnRelocate = () => this.cancelAdjust();
|
||
this.view?.addEventListener("relocate", this.adjustCancelOnRelocate, {
|
||
once: true,
|
||
});
|
||
},
|
||
ensureAdjustLayer(): HTMLElement {
|
||
let layer = document.getElementById("adjust-handles");
|
||
if (!layer) {
|
||
layer = document.createElement("div");
|
||
layer.id = "adjust-handles";
|
||
layer.style.position = "fixed";
|
||
layer.style.inset = "0";
|
||
layer.style.pointerEvents = "none";
|
||
layer.style.zIndex = "70";
|
||
document.body.appendChild(layer);
|
||
}
|
||
return layer;
|
||
},
|
||
/** Host-document coordinates of the working range's two ends. */
|
||
adjustHandlePoints(): {
|
||
start: DOMRect | null;
|
||
end: DOMRect | null;
|
||
frame: HTMLElement | null;
|
||
} {
|
||
const ctx = this.adjustContext;
|
||
if (!ctx) return { start: null, end: null, frame: null };
|
||
const frame = ctx.doc?.defaultView?.frameElement as HTMLElement | null;
|
||
const iframeRect = frame?.getBoundingClientRect();
|
||
const rStart = ctx.range.cloneRange();
|
||
rStart.collapse(true);
|
||
const rEnd = ctx.range.cloneRange();
|
||
rEnd.collapse(false);
|
||
const s = rStart.getBoundingClientRect();
|
||
const e = rEnd.getBoundingClientRect();
|
||
const map = (r: DOMRect) =>
|
||
new DOMRect(
|
||
(iframeRect?.left ?? 0) + r.left,
|
||
(iframeRect?.top ?? 0) + r.top,
|
||
r.width,
|
||
r.height,
|
||
);
|
||
return { start: map(s), end: map(e), frame };
|
||
},
|
||
positionAdjustHandles() {
|
||
const layer = document.getElementById("adjust-handles");
|
||
if (!layer) return;
|
||
const { start, end } = this.adjustHandlePoints();
|
||
const dots = layer.querySelectorAll<HTMLDivElement>(".adjust-handle");
|
||
dots.forEach((dot) => {
|
||
const r = dot.dataset.which === "start" ? start : end;
|
||
if (!r) return;
|
||
// Handles sit ON the boundary point; a 26px dot centered on it.
|
||
dot.style.left = `${r.left - 13}px`;
|
||
dot.style.top = `${r.top - 6}px`;
|
||
});
|
||
},
|
||
/** Drag move: map the pointer into the iframe's content coordinates,
|
||
* read the caret there, snap it, update the working range, and
|
||
* redraw the preview highlight. */
|
||
adjustMove(which: "start" | "end", clientX: number, clientY: number) {
|
||
const ctx = this.adjustContext;
|
||
if (!ctx) return;
|
||
const contents = this.renderer?.getContents?.() ?? [];
|
||
const content = contents.find((c: any) => c.index === ctx.index);
|
||
if (!content?.doc) return;
|
||
const frame = content.doc.defaultView?.frameElement as HTMLElement | null;
|
||
const iframeRect = frame?.getBoundingClientRect();
|
||
if (!iframeRect) return;
|
||
// The iframe is the coordinate space (AGENTS.md #18): client − frame
|
||
// offset = content point.
|
||
const x = clientX - (iframeRect.left ?? 0);
|
||
const y = clientY - (iframeRect.top ?? 0);
|
||
// caretRangeFromPoint is Chromium/WebKit; caretPositionFromPoint is
|
||
// the standards track — support both.
|
||
const caretRange =
|
||
content.doc.caretRangeFromPoint?.(x, y) ??
|
||
(() => {
|
||
const pos = content.doc.caretPositionFromPoint?.(x, y);
|
||
if (!pos) return null;
|
||
const r = content.doc.createRange();
|
||
r.setStart(pos.offsetNode, pos.offset);
|
||
r.collapse(true);
|
||
return r;
|
||
})();
|
||
if (!caretRange) return;
|
||
const snapped = this.snapBoundary(
|
||
caretRange.startContainer,
|
||
caretRange.startOffset,
|
||
which === "start",
|
||
);
|
||
if (!snapped) return;
|
||
// Rebuild the working range: the opposite end stays anchored.
|
||
const working = ctx.range.cloneRange();
|
||
try {
|
||
if (which === "start") working.setStart(snapped.node, snapped.offset);
|
||
else working.setEnd(snapped.node, snapped.offset);
|
||
} catch {
|
||
return;
|
||
}
|
||
// Degenerate (handles crossed): clamp to a point, never throw.
|
||
if (working.collapsed && which === "start") {
|
||
// start pushed past end: keep at the end boundary
|
||
working.setStart(snapped.node, snapped.offset);
|
||
}
|
||
ctx.range = working;
|
||
this.drawAdjustPreview();
|
||
this.positionAdjustHandles();
|
||
},
|
||
/** Word-boundary snap with punctuation precision (objective 9):
|
||
* land next to punctuation → the boundary sits exactly there;
|
||
* land inside a word → snap to that word's edge in the drag
|
||
* direction; land on whitespace → trim toward the word. */
|
||
snapBoundary(
|
||
node: Node,
|
||
offset: number,
|
||
isStart: boolean,
|
||
): { node: Node; offset: number } | null {
|
||
if (node.nodeType !== Node.TEXT_NODE) {
|
||
// Element landing: descend to the nearest text position.
|
||
const doc = node.ownerDocument;
|
||
if (!doc) return null;
|
||
const walker = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
||
const first = walker.nextNode();
|
||
if (first) return { node: first, offset: 0 };
|
||
return null;
|
||
}
|
||
const text = node.nodeValue ?? "";
|
||
const isWordChar = (ch: string) => /[\p{L}\p{N}]/u.test(ch);
|
||
const isPunct = (ch: string) => ch && !isWordChar(ch) && !/\s/.test(ch);
|
||
const before = offset > 0 ? text[offset - 1] : "";
|
||
const at = offset < text.length ? text[offset] : "";
|
||
// Punctuation precision: the boundary takes the exact caret spot
|
||
// when it touches punctuation (e.g. the period of "sentence.").
|
||
if ((before && isPunct(before)) || (at && isPunct(at))) {
|
||
return { node, offset };
|
||
}
|
||
if (at && isWordChar(at)) {
|
||
let o = offset;
|
||
if (isStart) {
|
||
while (o > 0 && isWordChar(text[o - 1])) o--;
|
||
} else {
|
||
while (o < text.length && isWordChar(text[o])) o++;
|
||
}
|
||
return { node, offset: o };
|
||
}
|
||
// Whitespace: trim toward the adjacent word (leading whitespace off
|
||
// a start boundary, trailing off an end boundary).
|
||
if (isStart) {
|
||
let o = offset;
|
||
while (o < text.length && !isWordChar(text[o]) && text[o] !== "") o++;
|
||
return { node, offset: o };
|
||
}
|
||
let o = offset;
|
||
while (o > 0 && !isWordChar(text[o - 1])) o--;
|
||
return { node, offset: o };
|
||
},
|
||
/** Live preview: repaint the working range in the overlayer under a
|
||
* scratch key (removed on the next move or on finalize). */
|
||
drawAdjustPreview() {
|
||
const ctx = this.adjustContext;
|
||
if (!ctx) return;
|
||
const contents = this.renderer?.getContents?.() ?? [];
|
||
const content = contents.find((c: any) => c.index === ctx.index);
|
||
if (!content?.overlayer) return;
|
||
content.overlayer.remove("__adjust-preview");
|
||
content.overlayer.add("__adjust-preview", ctx.range, Overlayer.highlight, {
|
||
color: ctx.color,
|
||
});
|
||
},
|
||
/** Release: recompute the anchors from the working range, save through
|
||
* the popover's own path, and clean the mode up. */
|
||
async adjustFinalize() {
|
||
const ctx = this.adjustContext;
|
||
if (!ctx) return;
|
||
const { index, range, id } = ctx;
|
||
const text = range.toString().replace(/\s+/g, " ").trim();
|
||
this.cancelAdjust();
|
||
if (!text) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
let cfi = "";
|
||
let cfiEnd = "";
|
||
try {
|
||
cfi = this.view.getCFI(index, range);
|
||
const endRange = range.cloneRange();
|
||
endRange.collapse(false);
|
||
cfiEnd = this.view.getCFI(index, endRange);
|
||
} catch {
|
||
showToast("Could not anchor the adjusted selection", "error");
|
||
return;
|
||
}
|
||
// Route through the popover state: saveHighlightChanges reads p.cfi /
|
||
// p.cfiEnd / p.text and handles the PUT + overlay re-render dance.
|
||
const p = this.selectionPopover;
|
||
p.id = id;
|
||
p.cfi = cfi;
|
||
p.cfiEnd = cfiEnd;
|
||
p.text = text;
|
||
await this.saveHighlightChanges();
|
||
},
|
||
cancelAdjust() {
|
||
const ctx = this.adjustContext;
|
||
if (ctx) {
|
||
const contents = this.renderer?.getContents?.() ?? [];
|
||
const content = contents.find((c: any) => c.index === ctx.index);
|
||
content?.overlayer?.remove("__adjust-preview");
|
||
}
|
||
document.getElementById("adjust-handles")?.remove();
|
||
if (this.adjustCancelOnRelocate) {
|
||
this.view?.removeEventListener?.("relocate", this.adjustCancelOnRelocate);
|
||
this.adjustCancelOnRelocate = null;
|
||
}
|
||
this.adjustContext = null;
|
||
},
|
||
// ----- PDF highlight extent adjustment (v2: endpoint handles) -----
|
||
/** Format router for the popover's Adjust button: PDF highlights
|
||
* adjust with two endpoint handles, reflowable ones with range
|
||
* handles. */
|
||
startAnyAdjust() {
|
||
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
|
||
else this.startAdjust();
|
||
},
|
||
/** Page→host mapping for a fixed-layout index. Basis: the page's
|
||
* WRAPPER element (the iframe's parent — the same box the fork's
|
||
* own rect-annotation overlay fills with percentage-positioned
|
||
* children). Measuring it is a single same-context host measurement:
|
||
* the previous version combined the iframe-internal canvas rect
|
||
* with the host iframe rect, and whether that mix is valid depends
|
||
* on which coordinate space a given engine returns for
|
||
* cross-document getBoundingClientRect — on desktop Brave it
|
||
* mismatched, so drags re-anchored "several lines down" and stuck.
|
||
* The wrapper is also what the highlights are painted against, so
|
||
* handle positions align with the paint by construction. */
|
||
pdfPageMapping(index: number) {
|
||
// The fixed-layout renderer's getContents() carries no index — map
|
||
// through the load-event registry, keeping only live frames.
|
||
const contents = this.renderer?.getContents?.() ?? [];
|
||
const doc = contents
|
||
.map((c: any) => c.doc)
|
||
.find((d: any) => d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement);
|
||
if (!doc) return null;
|
||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||
const wrapper = frame?.parentElement;
|
||
const wr = wrapper?.getBoundingClientRect();
|
||
if (!wr || !wr.width || !wr.height) return null;
|
||
const left = wr.left;
|
||
const top = wr.top;
|
||
const width = wr.width;
|
||
const height = wr.height;
|
||
return {
|
||
doc: doc as Document,
|
||
toHost(fx: number, fy: number) {
|
||
return { x: left + fx * width, y: top + fy * height };
|
||
},
|
||
toFraction(clientX: number, clientY: number) {
|
||
return {
|
||
fx: (clientX - left) / width,
|
||
fy: (clientY - top) / height,
|
||
};
|
||
},
|
||
};
|
||
},
|
||
/** Character glyph boxes (page fractions) in reading order, with
|
||
* dedupe: pdf.js re-renders (zoom settle, scale updates) append a
|
||
* second span set into the same .textLayer without clearing — char
|
||
* boxes come back duplicated, and the copies can sit SUB-PIXEL
|
||
* SHIFTED (a doubled-letter text recompute once slipped through an
|
||
* exact-position key). Dedupe by overlap instead: a candidate is a
|
||
* duplicate when a kept glyph on the same line overlaps more than
|
||
* half the narrower glyph. Distinct glyphs never reach that. */
|
||
pdfCharBoxes(doc: Document): {
|
||
x0: number;
|
||
x1: number;
|
||
y0: number;
|
||
y1: number;
|
||
ch: string;
|
||
}[] {
|
||
const canvas =
|
||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||
(doc.querySelector("img") as HTMLElement);
|
||
const dr = canvas?.getBoundingClientRect();
|
||
if (!dr || !dr.width || !dr.height) return [];
|
||
const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] = [];
|
||
const range = doc.createRange();
|
||
const spans = doc.querySelectorAll(".textLayer span");
|
||
spans.forEach((span) => {
|
||
const node = span.firstChild;
|
||
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||
const text = node.nodeValue ?? "";
|
||
for (let i = 0; i < text.length; i++) {
|
||
if (/\s/.test(text[i])) continue;
|
||
range.setStart(node, i);
|
||
range.setEnd(node, i + 1);
|
||
const r = range.getBoundingClientRect();
|
||
if (!r.width && !r.height) continue;
|
||
const box = {
|
||
x0: (r.left - dr.left) / dr.width,
|
||
x1: (r.right - dr.left) / dr.width,
|
||
y0: (r.top - dr.top) / dr.height,
|
||
y1: (r.bottom - dr.top) / dr.height,
|
||
ch: text[i],
|
||
};
|
||
if (box.x1 <= 0 || box.x0 >= 1 || box.y1 <= 0 || box.y0 >= 1) continue;
|
||
const w = box.x1 - box.x0;
|
||
const h = box.y1 - box.y0;
|
||
const dup = out.some((k) => {
|
||
// A duplicate is the SAME glyph re-rendered at a drifted
|
||
// position: y within 0.75 of the SHORTER box (per-char boxes
|
||
// are LINE-HEIGHT boxes — a full-height tolerance let a
|
||
// line-1 char eat the line-2 char beneath it in the same
|
||
// x-column), x overlapping more than half the narrower box.
|
||
// The staircase of offset re-render copies steps by ~0.004 —
|
||
// well inside this tolerance; real inter-line gaps (~0.0196)
|
||
// are outside it.
|
||
const kh = k.y1 - k.y0;
|
||
const tolY = Math.min(kh, h) * 0.75;
|
||
if (Math.abs(k.y0 - box.y0) > tolY) return false;
|
||
const kw = k.x1 - k.x0;
|
||
const overlap = Math.min(k.x1, box.x1) - Math.max(k.x0, box.x0);
|
||
return overlap > Math.min(kw, w) * 0.5;
|
||
});
|
||
if (dup) continue;
|
||
out.push(box);
|
||
}
|
||
});
|
||
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
|
||
return out;
|
||
},
|
||
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
|
||
* build the page's character table (once — every drag reads the
|
||
* cached table in pure fraction space; no DOM reads, no
|
||
* coordinate-space hazards — portable to any browser engine), map
|
||
* the stored rects to a word... char range, put the two endpoint
|
||
* handles up, and arm the cancel paths. */
|
||
startPdfAdjust() {
|
||
const p = this.selectionPopover;
|
||
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
|
||
const map = this.pdfPageMapping(p.pdfPage);
|
||
if (!map) return;
|
||
const chars = this.pdfCharBoxes(map.doc);
|
||
if (!chars.length) return;
|
||
// The extent's char range: chars whose centers sit inside the
|
||
// stored rects (creation- and adjust-made extents both cover their
|
||
// own glyphs).
|
||
let startI = -1;
|
||
let endI = -1;
|
||
chars.forEach((c, i) => {
|
||
const cx = (c.x0 + c.x1) / 2;
|
||
const cy = (c.y0 + c.y1) / 2;
|
||
if (
|
||
p.pdfRects.some((r) => cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3])
|
||
) {
|
||
if (startI < 0) startI = i;
|
||
endI = i;
|
||
}
|
||
});
|
||
if (startI < 0) return;
|
||
this.pdfAdjust = {
|
||
id: p.id,
|
||
index: p.pdfPage,
|
||
page: p.pdfPage,
|
||
color: p.color,
|
||
rects: p.pdfRects.map((r) => [...r]),
|
||
original: p.pdfRects.map((r) => [...r]),
|
||
chars,
|
||
startI,
|
||
endI,
|
||
committed: false,
|
||
};
|
||
this.hideSelectionPopover();
|
||
const layer = this.ensureAdjustLayer();
|
||
layer.innerHTML = "";
|
||
this.renderPdfAdjustHandles();
|
||
// A page turn ends the session safely — the preview is discarded,
|
||
// nothing saved (same contract as the reflowable adjust).
|
||
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
|
||
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
|
||
once: true,
|
||
});
|
||
// Zoom re-renders the page — re-derive the handles from live rects.
|
||
this.pdfAdjustOnZoom = () => this.renderPdfAdjustHandles();
|
||
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
|
||
},
|
||
/** (Re)draw the two endpoint handles, positioned through the live
|
||
* page mapping. The dots are created ONCE per session — every later
|
||
* call only repositions the existing elements (an innerHTML rebuild
|
||
* mid-drag destroys the dot being dragged; it loses pointer capture
|
||
* and the drag dies silently — user report). */
|
||
renderPdfAdjustHandles() {
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
const layer = document.getElementById("adjust-handles");
|
||
if (!layer) return;
|
||
const map = this.pdfPageMapping(ctx.index);
|
||
if (!map) return;
|
||
const first = ctx.rects[0];
|
||
const last = ctx.rects[ctx.rects.length - 1];
|
||
if (!first || !last) return;
|
||
// Start dot floating ABOVE the first rect's top-left corner, end
|
||
// dot above the last rect's top-right corner — the teardrop tip
|
||
// points down at the boundary so the highlighted text underneath
|
||
// stays readable (user request: the handle covered the text).
|
||
const pts: Array<{ which: "start" | "end"; fx: number; fy: number }> = [
|
||
{ which: "start", fx: first[0], fy: first[1] },
|
||
{ which: "end", fx: last[0] + last[2], fy: last[1] },
|
||
];
|
||
for (const pt of pts) {
|
||
let dot = layer.querySelector<HTMLDivElement>(`.adjust-handle[data-which="${pt.which}"]`);
|
||
if (!dot) {
|
||
dot = document.createElement("div");
|
||
dot.className = "adjust-handle";
|
||
dot.dataset.which = pt.which;
|
||
const which = pt.which;
|
||
// Pointer events (mouse + touch + pen); the page's own touch
|
||
// handlers are not engaged because the dots live in the HOST
|
||
// document, above the iframe.
|
||
dot.addEventListener("pointerdown", (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
dot!.setPointerCapture(e.pointerId);
|
||
} catch {}
|
||
dot!.dataset.dragging = "1";
|
||
});
|
||
dot.addEventListener("pointermove", (e) => {
|
||
if (!dot!.dataset.dragging) return;
|
||
// Aim at the TIP (finger + ~15px, the body-center-to-tip
|
||
// offset): the body floats above the text, the tip shows
|
||
// where the boundary actually is.
|
||
this.pdfAdjustHandleMove(which, e.clientX, e.clientY + 15);
|
||
});
|
||
dot.addEventListener("pointerup", (e) => {
|
||
if (!dot!.dataset.dragging) return;
|
||
delete dot!.dataset.dragging;
|
||
try {
|
||
dot!.releasePointerCapture?.(e.pointerId);
|
||
} catch {}
|
||
this.pdfAdjustRelease();
|
||
});
|
||
dot.addEventListener("pointercancel", () => {
|
||
delete dot!.dataset.dragging;
|
||
});
|
||
layer.appendChild(dot);
|
||
}
|
||
const host = map.toHost(pt.fx, pt.fy);
|
||
// The teardrop's rotated tip is its bottom point: hang the box
|
||
// ABOVE the boundary corner, tip resting ~4px above the glyph
|
||
// tops — a clear gap so the tip never covers letters (Android
|
||
// handle spacing).
|
||
dot.style.left = `${host.x - 13}px`;
|
||
dot.style.top = `${host.y - 30}px`;
|
||
}
|
||
},
|
||
/** Line boxes (page fractions) of a contiguous char-table span:
|
||
* cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT
|
||
* boxes (~one line gap tall), so the break threshold must sit
|
||
* clearly below the inter-line gap yet above intra-line baseline
|
||
* variance — 0.6 of the char's own height does that (gap ≈ 1.0 ×
|
||
* height breaks; variance ≈ 0.1 × height stays). */
|
||
charSpanRects(chars: { x0: number; x1: number; y0: number; y1: number }[]): number[][] {
|
||
const rects: number[][] = [];
|
||
let line: typeof chars = [];
|
||
let lastCy = -9;
|
||
for (const c of chars) {
|
||
const cy = (c.y0 + c.y1) / 2;
|
||
const tol = (c.y1 - c.y0) * 0.6;
|
||
if (line.length && Math.abs(cy - lastCy) > tol) {
|
||
const x0 = Math.min(...line.map((w) => w.x0));
|
||
const y0 = Math.min(...line.map((w) => w.y0));
|
||
const x1 = Math.max(...line.map((w) => w.x1));
|
||
const y1 = Math.max(...line.map((w) => w.y1));
|
||
rects.push([x0, y0, x1 - x0, y1 - y0]);
|
||
line = [];
|
||
}
|
||
line.push(c);
|
||
lastCy = cy;
|
||
}
|
||
if (line.length) {
|
||
const x0 = Math.min(...line.map((w) => w.x0));
|
||
const y0 = Math.min(...line.map((w) => w.y0));
|
||
const x1 = Math.max(...line.map((w) => w.x1));
|
||
const y1 = Math.max(...line.map((w) => w.y1));
|
||
rects.push([x0, y0, x1 - x0, y1 - y0]);
|
||
}
|
||
return rects;
|
||
},
|
||
/** Endpoint drag: the finger (HOST px) maps to page fractions via
|
||
* the live mapping, then re-anchors the grabbed end at the nearest
|
||
* glyph in the cached char table — character-precise (a trailing
|
||
* period or line-break hyphen can be taken or shed), crossing
|
||
* lines freely — and the rects rebuild from the new char span as
|
||
* per-line unions. Pure fraction space after the mapping: no
|
||
* caretRangeFromPoint, no coordinate-space conversion, works on
|
||
* any browser engine (Firefox included). */
|
||
pdfAdjustHandleMove(which: "start" | "end", clientX: number, clientY: number) {
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
const map = this.pdfPageMapping(ctx.index);
|
||
if (!map) return;
|
||
const { fx, fy } = map.toFraction(clientX, clientY);
|
||
const chars = ctx.chars;
|
||
if (!chars.length) return;
|
||
// Full 2D nearest glyph: the tip crosses lines freely (up/down)
|
||
// and moves along them (side to side). No line-band pin — the
|
||
// lifted handle's finger sits above the text, and a y-band filter
|
||
// here made vertical drags impossible (user report). Sliver rects
|
||
// stay impossible: the extent is a contiguous char span and the
|
||
// rects are its per-line unions.
|
||
let best = 0;
|
||
let bestD = Infinity;
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const c = chars[i];
|
||
const cx = (c.x0 + c.x1) / 2;
|
||
const cy = (c.y0 + c.y1) / 2;
|
||
const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
|
||
if (d < bestD) {
|
||
bestD = d;
|
||
best = i;
|
||
}
|
||
}
|
||
let s = ctx.startI;
|
||
let e = ctx.endI;
|
||
if (which === "start") s = Math.min(best, e);
|
||
else e = Math.max(best, s);
|
||
if (s > e) return;
|
||
ctx.startI = s;
|
||
ctx.endI = e;
|
||
ctx.rects = this.charSpanRects(chars.slice(s, e + 1));
|
||
this.schedulePdfAdjustPreview();
|
||
},
|
||
/** Preview scheduling: coalesce pointermove bursts into one overlay
|
||
* repaint per frame. The preview replaces the highlight's OWN
|
||
* overlay entry (addRectAnnotation is replace-by-key), so there is
|
||
* never a double paint and the save re-adds the same key. */
|
||
schedulePdfAdjustPreview() {
|
||
if (this.pdfAdjustRaf) return;
|
||
this.pdfAdjustRaf = requestAnimationFrame(() => {
|
||
this.pdfAdjustRaf = 0;
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
this.renderer?.addRectAnnotation?.({
|
||
key: ctx.id,
|
||
index: ctx.index,
|
||
rects: ctx.rects,
|
||
color: ctx.color,
|
||
});
|
||
this.renderPdfAdjustHandles();
|
||
});
|
||
},
|
||
/** Release: the extent's chars ARE the text — join them (line gaps
|
||
* re-insert spaces, matching the creation path's normalized
|
||
* selection text) and save through the popover's own PUT path. */
|
||
async pdfAdjustRelease() {
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1);
|
||
let text = "";
|
||
let prev: (typeof chars)[number] | null = null;
|
||
for (const c of chars) {
|
||
if (prev) {
|
||
const pc = (prev.y0 + prev.y1) / 2;
|
||
const cc = (c.y0 + c.y1) / 2;
|
||
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
|
||
if (Math.abs(cc - pc) > half) text += " ";
|
||
else if (c.x0 - prev.x1 > 0.004) text += " ";
|
||
}
|
||
text += c.ch;
|
||
prev = c;
|
||
}
|
||
text = text.replace(/\s+/g, " ").trim();
|
||
const rects = ctx.rects;
|
||
const { id, page } = ctx;
|
||
ctx.committed = true;
|
||
this.cancelPdfAdjust();
|
||
if (!text) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
const p = this.selectionPopover;
|
||
p.id = id;
|
||
p.pdfPage = page;
|
||
p.pdfRects = rects;
|
||
p.text = text;
|
||
await this.saveHighlightChanges();
|
||
},
|
||
/** Leave rect adjust: restore the stored paint unless the adjustment
|
||
* was committed (cancel runs after finalize too), and drop the mode. */
|
||
cancelPdfAdjust() {
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
if (!ctx.committed) {
|
||
this.renderer?.addRectAnnotation?.({
|
||
key: ctx.id,
|
||
index: ctx.index,
|
||
rects: ctx.original,
|
||
color: ctx.color,
|
||
});
|
||
}
|
||
this.pdfAdjust = null;
|
||
if (this.pdfAdjustRaf) {
|
||
cancelAnimationFrame(this.pdfAdjustRaf);
|
||
this.pdfAdjustRaf = 0;
|
||
}
|
||
if (this.pdfAdjustOnRelocate) {
|
||
this.renderer?.removeEventListener?.("relocate", this.pdfAdjustOnRelocate);
|
||
this.pdfAdjustOnRelocate = null;
|
||
}
|
||
if (this.pdfAdjustOnZoom) {
|
||
this.renderer?.removeEventListener?.("zoom", this.pdfAdjustOnZoom);
|
||
this.pdfAdjustOnZoom = null;
|
||
}
|
||
document.getElementById("adjust-handles")?.remove();
|
||
},
|
||
|
||
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;
|
||
// Adjust re-selection (v2): a selection made during a rect-edge
|
||
// session REPLACES the working extent — any lines, partial lines
|
||
// (the exact gap the handles alone could not express from stale
|
||
// geometry). Chars inside the selection's line boxes become the
|
||
// new extent; text reads from them; saved immediately: the
|
||
// handles' release-to-save parity.
|
||
if (this.pdfAdjust) {
|
||
const canvasRect =
|
||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||
(doc.querySelector("img") as HTMLElement);
|
||
const dr = canvasRect?.getBoundingClientRect();
|
||
if (!dr || !dr.width || !dr.height) return;
|
||
const selRects: 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) selRects.push([x, y, w, h]);
|
||
}
|
||
if (!selRects.length) return;
|
||
const chars = this.pdfAdjust.chars;
|
||
const picked: number[] = [];
|
||
chars.forEach((c, i) => {
|
||
const cx = (c.x0 + c.x1) / 2;
|
||
const cy = (c.y0 + c.y1) / 2;
|
||
if (
|
||
selRects.some((r) => cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3])
|
||
)
|
||
picked.push(i);
|
||
});
|
||
if (!picked.length) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
const session = this.pdfAdjust;
|
||
const startI = picked[0];
|
||
const endI = picked[picked.length - 1];
|
||
const extentRects = this.charSpanRects(chars.slice(startI, endI + 1));
|
||
let t = "";
|
||
let prev: (typeof chars)[number] | null = null;
|
||
for (const c of chars.slice(startI, endI + 1)) {
|
||
if (prev) {
|
||
const pc = (prev.y0 + prev.y1) / 2;
|
||
const cc = (c.y0 + c.y1) / 2;
|
||
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
|
||
if (Math.abs(cc - pc) > half) t += " ";
|
||
else if (c.x0 - prev.x1 > 0.004) t += " ";
|
||
}
|
||
t += c.ch;
|
||
prev = c;
|
||
}
|
||
t = t.replace(/\s+/g, " ").trim();
|
||
if (!t) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
session.committed = true;
|
||
this.cancelPdfAdjust();
|
||
const p = this.selectionPopover;
|
||
p.id = session.id;
|
||
p.pdfPage = session.page;
|
||
p.pdfRects = extentRects;
|
||
p.text = t;
|
||
this.saveHighlightChanges();
|
||
try {
|
||
sel.removeAllRanges();
|
||
} catch {
|
||
/* doc may be gone */
|
||
}
|
||
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) {
|
||
this.renderer?.addRectAnnotation?.({
|
||
key: hl.id,
|
||
index: hl.pdfPage,
|
||
rects: hl.pdfRects,
|
||
color: hl.color,
|
||
});
|
||
} else {
|
||
this.view
|
||
?.addAnnotation({
|
||
value: hl.renderCfi || hl.cfi,
|
||
color: hl.color,
|
||
note: hl.note,
|
||
id: hl.id,
|
||
})
|
||
?.catch?.(() => {});
|
||
}
|
||
}
|
||
},
|
||
mapHighlightRow(r: any) {
|
||
let cfi = r.epubcfi_start ?? "";
|
||
let pdfPage = -1;
|
||
let pdfRects: number[][] = [];
|
||
// Fixed-layout anchors are stored as a JSON descriptor in the CFI
|
||
// column (page index + page-fraction rects).
|
||
if (cfi.startsWith("{")) {
|
||
try {
|
||
const a = JSON.parse(cfi);
|
||
if (a && typeof a.page === "number" && Array.isArray(a.rects) && a.rects.length) {
|
||
pdfPage = a.page;
|
||
pdfRects = a.rects;
|
||
cfi = "";
|
||
}
|
||
} catch {
|
||
/* not ours; leave as-is */
|
||
}
|
||
}
|
||
const cfiEnd = r.epubcfi_end ?? "";
|
||
return {
|
||
id: r.id,
|
||
text: r.selection_text ?? "",
|
||
note: r.note_text ?? "",
|
||
noteHtml: renderNoteHtml(r.note_text ?? ""),
|
||
color: r.color ?? "#ffff00",
|
||
cfi,
|
||
cfiEnd,
|
||
// Rendering/navigating anchor: device-synced highlights store
|
||
// POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a
|
||
// collapsed range and paint nothing. Foliate's overlayer needs a
|
||
// RANGE CFI — same shape getCFI() produces natively
|
||
// (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from
|
||
// the stored start and end points when both share a base path.
|
||
renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""),
|
||
percentage: r.percentage_start ?? 0,
|
||
pdfPage,
|
||
pdfRects,
|
||
};
|
||
},
|
||
// Build a foliate-renderable RANGE CFI from stored (possibly point)
|
||
// CFIs. Repairs two stale shapes using the selection text: a missing
|
||
// end (old web highlights), and a degenerate end — the device-push
|
||
// converter used to fall back to a document-start CFI when the end
|
||
// xpointer didn't resolve exactly. In both cases the end is derived
|
||
// from the start offset advanced by the text's UTF-16 length (EPUB
|
||
// CFI offsets are UTF-16 code units).
|
||
//
|
||
// The endpoints may legitimately anchor at different nodes (a
|
||
// selection crossing inline spans lands in two text nodes), so the
|
||
// range parent is the endpoints' longest shared step prefix — down to
|
||
// the bare spine step if needed — and each side emits its divergent
|
||
// remainder, per the EPUB CFI range grammar
|
||
// epubcfi(parent,start,end).
|
||
toRangeCfi(start: string, end: string, text: string): string {
|
||
if (!start) return end || start;
|
||
if (start.includes(",")) return start; // already a range CFI
|
||
const spineRe = /^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/;
|
||
const parseCfi = (cfi: string) => {
|
||
const m = spineRe.exec(cfi);
|
||
if (!m) return null;
|
||
const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map((s) => s);
|
||
return { spine: m[1], steps, off: m[3] ? parseInt(m[3], 10) : -1 };
|
||
};
|
||
const s = parseCfi(start);
|
||
if (!s || s.steps.length === 0) return start;
|
||
const utf16len = [...(text ?? "")].reduce(
|
||
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
|
||
0,
|
||
);
|
||
// Range with the parent one step short of the terminal, so both
|
||
// subpaths keep at least their terminal step; offsets ride on the
|
||
// subpath terminators.
|
||
const rem = (steps: string[], off: number) =>
|
||
off >= 0 ? `${steps.join("")}:${off}` : steps.join("");
|
||
const advanceEnd = () => {
|
||
if (s.off < 0 || utf16len <= 0) return null;
|
||
const last = s.steps[s.steps.length - 1];
|
||
const parent = s.spine + s.steps.slice(0, -1).join("");
|
||
return `${parent},${rem([last], s.off)},${rem([last], s.off + utf16len)})`;
|
||
};
|
||
const e = end && !end.includes(",") ? parseCfi(end) : null;
|
||
if (!e || e.spine !== s.spine || e.steps.length === 0) {
|
||
const adv = advanceEnd();
|
||
return adv ?? start; // point CFI
|
||
}
|
||
let common = 0;
|
||
const maxCommon = Math.min(s.steps.length, e.steps.length);
|
||
while (common < maxCommon && s.steps[common] === e.steps[common]) {
|
||
common++;
|
||
}
|
||
if (common === maxCommon) common = maxCommon - 1;
|
||
// Same terminal node only when the single remaining steps are
|
||
// EQUAL — one step per side with different step values is a
|
||
// legitimate two-node range. Degenerate ends (document-start
|
||
// fallback of the old converter, or an end before the start) are
|
||
// repaired via the UTF-16 advance.
|
||
if (
|
||
common === s.steps.length - 1 &&
|
||
common === e.steps.length - 1 &&
|
||
s.steps[common] === e.steps[common]
|
||
) {
|
||
const degenerate = e.off === 0 || (s.off >= 0 && e.off >= 0 && e.off < s.off);
|
||
if (degenerate) {
|
||
const adv = advanceEnd();
|
||
if (adv) return adv;
|
||
}
|
||
} else {
|
||
// Different terminal nodes: require end after start in document
|
||
// order; otherwise repair.
|
||
const ai = parseInt(s.steps[common].slice(1), 10);
|
||
const bi = parseInt(e.steps[common].slice(1), 10);
|
||
if (bi < ai) {
|
||
const adv = advanceEnd();
|
||
return adv ?? start;
|
||
}
|
||
}
|
||
const parent = s.spine + s.steps.slice(0, common).join("");
|
||
return `${parent},${rem(s.steps.slice(common), s.off)},${rem(e.steps.slice(common), e.off)})`;
|
||
},
|
||
async refreshAnnotations() {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
try {
|
||
const [hlResp, noteResp] = await Promise.all([
|
||
fetch(`/api/media-items/${this.mediaItemId}/highlights`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
}),
|
||
fetch(`/api/media-items/${this.mediaItemId}/notes`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
}),
|
||
]);
|
||
if (hlResp.ok) {
|
||
const rows = await hlResp.json();
|
||
this.highlightItems = (rows as any[])
|
||
.map((r) => this.mapHighlightRow(r))
|
||
// Comic-treated items show no highlights (bookmarks only),
|
||
// and previously-created PDF highlights stop rendering.
|
||
.filter((hl: any) => !this.treatAsComic && (hl.cfi || hl.pdfPage >= 0));
|
||
this.renderAllHighlights();
|
||
}
|
||
if (noteResp.ok) {
|
||
const rows = await noteResp.json();
|
||
this.noteItems = (rows as any[]).map((r) => ({
|
||
id: r.id,
|
||
content: r.content ?? "",
|
||
contentHtml: renderNoteHtml(r.content ?? ""),
|
||
positionLabel: r.position ?? "",
|
||
}));
|
||
}
|
||
} catch {
|
||
/* annotations are non-critical; leave lists as-is */
|
||
}
|
||
},
|
||
async createHighlight(color: string) {
|
||
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();
|
||
if (this.noteOverLimit(note)) {
|
||
showToast(
|
||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${note.length.toLocaleString()}). Shrink it and try again.`,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
const pdfAnchor =
|
||
p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : "";
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
selection_text: p.text,
|
||
start_position: "",
|
||
end_position: "",
|
||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||
color,
|
||
note_text: note,
|
||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||
}),
|
||
});
|
||
if (!resp.ok) return;
|
||
const row = await resp.json();
|
||
const hl = this.mapHighlightRow(row);
|
||
this.highlightItems.push(hl);
|
||
if (hl.pdfPage >= 0) {
|
||
this.renderer?.addRectAnnotation?.({
|
||
key: hl.id,
|
||
index: hl.pdfPage,
|
||
rects: hl.pdfRects,
|
||
color,
|
||
});
|
||
// Clear the textLayer selection so the highlight reads cleanly.
|
||
try {
|
||
this.pdfSelDoc?.getSelection()?.removeAllRanges();
|
||
} catch {
|
||
/* doc may be gone */
|
||
}
|
||
} else {
|
||
this.view?.addAnnotation({
|
||
value: p.pdfPage >= 0 ? "" : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||
color,
|
||
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 {
|
||
/* ignore highlight errors */
|
||
}
|
||
},
|
||
async saveHighlightChanges(keepNoteOpen = false) {
|
||
const p = this.selectionPopover;
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId || !p.id) return;
|
||
if (this.noteOverLimit(p.note)) {
|
||
showToast(
|
||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${p.note.length.toLocaleString()}). Shrink it and try again.`,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
const anchor =
|
||
p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : p.cfi;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights/${p.id}`, {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
selection_text: p.text,
|
||
start_position: "",
|
||
end_position: "",
|
||
epubcfi_start: anchor,
|
||
epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd,
|
||
color: p.color,
|
||
note_text: p.note,
|
||
}),
|
||
});
|
||
if (!resp.ok) return;
|
||
const row = await resp.json();
|
||
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
||
// The overlay is keyed by the value it was added with; an edit can
|
||
// change it (note/text edits change the synthesized range), so
|
||
// remove the old paint before re-adding or it ghosts.
|
||
const oldValue =
|
||
idx !== -1 ? this.highlightItems[idx].renderCfi || this.highlightItems[idx].cfi : "";
|
||
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
||
const newValue =
|
||
idx !== -1 ? this.highlightItems[idx].renderCfi || this.highlightItems[idx].cfi : "";
|
||
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
|
||
this.view?.deleteAnnotation({ value: oldValue });
|
||
}
|
||
// Re-add so the overlay redraws with the new color.
|
||
if (p.pdfPage >= 0) {
|
||
this.renderer?.addRectAnnotation?.({
|
||
key: p.id,
|
||
index: p.pdfPage,
|
||
rects: p.pdfRects,
|
||
color: p.color,
|
||
});
|
||
} else {
|
||
this.view?.addAnnotation({
|
||
value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||
color: p.color,
|
||
note: p.note,
|
||
id: p.id,
|
||
});
|
||
}
|
||
// 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 {
|
||
/* ignore highlight errors */
|
||
}
|
||
},
|
||
async deleteHighlightById(id: string) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
const hl = this.highlightItems.find((h) => h.id === id);
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights/${id}`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!resp.ok && resp.status !== 204) return;
|
||
this.highlightItems = this.highlightItems.filter((h) => h.id !== id);
|
||
if (hl?.pdfPage >= 0) {
|
||
this.renderer?.removeRectAnnotation?.(id);
|
||
} else if (hl?.cfi) {
|
||
// Delete with the value the overlay was added by: device-synced
|
||
// highlights paint a synthesized range, not the stored point CFI.
|
||
this.view?.deleteAnnotation({
|
||
value: hl.renderCfi || hl.cfi,
|
||
});
|
||
}
|
||
this.hideSelectionPopover();
|
||
} catch {
|
||
/* ignore highlight errors */
|
||
}
|
||
},
|
||
async copySelectionText() {
|
||
const text = this.selectionPopover.text;
|
||
try {
|
||
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; renderCfi: string; pdfPage: number }) {
|
||
if (hl.pdfPage >= 0) {
|
||
// Fixed-layout: a bare number navigates to the section (page) index.
|
||
this.pushBackStack();
|
||
this.view?.goTo?.(hl.pdfPage);
|
||
this.closeDrawers();
|
||
} else if (hl.renderCfi || hl.cfi) {
|
||
this.pushBackStack();
|
||
this.view?.showAnnotation({ value: hl.renderCfi || hl.cfi })?.catch?.(() => {});
|
||
this.closeDrawers();
|
||
}
|
||
},
|
||
noteOverLimit(text: string): boolean {
|
||
return text.length > this.noteMaxLength;
|
||
},
|
||
noteCountLabel(text: string): string {
|
||
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
||
},
|
||
// Rich clipboard (text/html) converts to markdown source at the caret;
|
||
// plain-only clipboards fall through to the browser default paste.
|
||
richNotePaste(event: ClipboardEvent) {
|
||
const markdown = richPasteToMarkdown(event);
|
||
if (markdown === null) return;
|
||
event.preventDefault();
|
||
const el = event.target as HTMLTextAreaElement | null;
|
||
if (!el) return;
|
||
const start = el.selectionStart ?? el.value.length;
|
||
const end = el.selectionEnd ?? start;
|
||
el.value = el.value.slice(0, start) + markdown + el.value.slice(end);
|
||
const caret = start + markdown.length;
|
||
el.setSelectionRange(caret, caret);
|
||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||
},
|
||
async addNote(content: string) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId || !content.trim()) return;
|
||
if (this.noteOverLimit(content)) {
|
||
showToast(
|
||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${content.length.toLocaleString()}). Shrink it and try again.`,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
content,
|
||
position: !this.isFixedLayout
|
||
? `cfi:${this.view?.lastLocation?.cfi ?? ""}`
|
||
: `page:${(this.renderer?.index ?? 0) + 1}`,
|
||
}),
|
||
});
|
||
if (!resp.ok) return;
|
||
await this.refreshAnnotations();
|
||
} catch {
|
||
/* ignore note errors */
|
||
}
|
||
},
|
||
/** Note edit at the annotations slider (objective 8a): inline, mirrors
|
||
* the bookmark-rename pattern. The PUT carries the stored position —
|
||
* the server hard-sets it, so an omitted position would strip the
|
||
* jump anchor. */
|
||
startNoteEdit(note: any) {
|
||
note.editText = note.content ?? "";
|
||
note.editOpen = true;
|
||
},
|
||
cancelNoteEdit(note: any) {
|
||
note.editOpen = false;
|
||
note.editText = "";
|
||
},
|
||
async saveNoteEdit(note: any) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
const content = String(note.editText ?? "").trim();
|
||
if (!content) return;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes/${note.id}`, {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
// The stored position MUST ride along: the server's PUT
|
||
// hard-sets the position column (an omitted position
|
||
// would strip the jump anchor).
|
||
body: JSON.stringify({
|
||
content,
|
||
position: note.positionLabel || (note.content && note.position) || "",
|
||
}),
|
||
});
|
||
if (!resp.ok) return;
|
||
const row: any = await resp.json();
|
||
note.content = row.content ?? content;
|
||
note.contentHtml = renderNoteHtml(note.content);
|
||
note.positionLabel = row.position ?? note.positionLabel;
|
||
note.editOpen = false;
|
||
note.editText = "";
|
||
} catch {
|
||
/* note errors are non-critical */
|
||
}
|
||
},
|
||
async deleteNoteById(id: string) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes/${id}`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (resp.ok || resp.status === 204) {
|
||
this.noteItems = this.noteItems.filter((n) => n.id !== id);
|
||
}
|
||
} catch {
|
||
/* ignore note errors */
|
||
}
|
||
},
|
||
// Fresh reading position from the database — the single source of
|
||
// truth at open time. Fails soft to a fresh start: change detection
|
||
// against the restored baseline guarantees merely opening (even at
|
||
// the wrong spot) can never overwrite the stored position.
|
||
async fetchSavedLocation(): Promise<{
|
||
cfi?: string;
|
||
page?: number;
|
||
percentage?: number;
|
||
}> {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return {};
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
cache: "no-store",
|
||
});
|
||
if (!resp.ok) return {};
|
||
const row: any = await resp.json();
|
||
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
|
||
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
|
||
// The stored percentage is a 0-1 fraction.
|
||
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
|
||
return {
|
||
cfi: typeof cfi === "string" ? cfi : "",
|
||
page: typeof page === "number" ? page : 0,
|
||
percentage: typeof pct === "number" ? pct : 0,
|
||
};
|
||
} catch {
|
||
return {};
|
||
}
|
||
},
|
||
/** ?loc= handoff from the book-detail annotations banner (objective 7):
|
||
* `cfi:<epubcfi>` or `page:<1-based>`; outranks the stored progress for
|
||
* this open only. Parsed once; consumed + stripped from the URL so a
|
||
* reload (or device sync / back navigation) restores normally. */
|
||
consumeLocJump(): { cfi?: string; page?: number } | null {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const raw = params.get("loc");
|
||
if (!raw) return null;
|
||
params.delete("loc");
|
||
const qs = params.toString();
|
||
const base = window.location.pathname;
|
||
window.history.replaceState({}, "", base + (qs ? `?${qs}` : ""));
|
||
const value = decodeURIComponent(raw);
|
||
if (value.startsWith("cfi:")) {
|
||
return { cfi: value.slice(4) };
|
||
}
|
||
if (value.startsWith("page:")) {
|
||
const n = parseInt(value.slice(5), 10);
|
||
if (Number.isFinite(n) && n > 0) return { page: n };
|
||
}
|
||
return {};
|
||
},
|
||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||
// Only an actual change from the last stored position writes
|
||
// progress: displaying a restored position must never overwrite a
|
||
// newer device push. Swipes and scrolls are handled inside foliate
|
||
// with no wrapper method to flag, so position — not intent — is the
|
||
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
|
||
const currentCfi = cfi || "";
|
||
const changed =
|
||
currentCfi || this.lastSyncedCfi
|
||
? currentCfi !== this.lastSyncedCfi
|
||
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
|
||
if (!changed) return;
|
||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||
this.saveTimeout = setTimeout(() => {
|
||
this.saveProgress(fraction, location, cfi);
|
||
}, 2000);
|
||
},
|
||
async saveProgress(fraction: number, location: any, cfi: string) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
|
||
const body: Record<string, any> = {
|
||
percentage: fraction,
|
||
current_page: this.isFixedLayout
|
||
? (this.renderer?.index ?? 0) + 1
|
||
: (location?.current ?? 0),
|
||
total_pages: this.isFixedLayout
|
||
? (this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0)
|
||
: (location?.total ?? 0),
|
||
reading_mode: this.readingMode || undefined,
|
||
};
|
||
if (!this.isFixedLayout) {
|
||
body.epubcfi = cfi || "";
|
||
body.context_text = this.contextText || "";
|
||
}
|
||
|
||
const tocItem = this.lastRelocateDetail?.tocItem;
|
||
if (tocItem) {
|
||
if (tocItem.label) {
|
||
const boundaryIdx = this.chapterBoundaries.findIndex(
|
||
(b: any) => b.label === tocItem.label,
|
||
);
|
||
if (boundaryIdx !== -1) {
|
||
body.chapter = boundaryIdx;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.isFixedLayout && this.renderer?.zoomPercent) {
|
||
body.zoom_level = this.renderer.zoomPercent / 100;
|
||
}
|
||
|
||
try {
|
||
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
this.lastSyncedCfi = cfi || "";
|
||
this.lastSyncedFraction = fraction;
|
||
} catch {
|
||
// silent fail — progress save is non-critical
|
||
}
|
||
},
|
||
zoomIn() {
|
||
if (!this.isFixedLayout) return;
|
||
const newScale = Math.min(10, this.renderer.currentScale * 1.2);
|
||
this.renderer.setAttribute("zoom", newScale);
|
||
this.zoomPercent = this.renderer.zoomPercent;
|
||
},
|
||
zoomOut() {
|
||
if (!this.isFixedLayout) return;
|
||
const newScale = Math.max(0.1, this.renderer.currentScale / 1.2);
|
||
this.renderer.setAttribute("zoom", newScale);
|
||
this.zoomPercent = this.renderer.zoomPercent;
|
||
},
|
||
resetZoom() {
|
||
if (!this.isFixedLayout) return;
|
||
this.renderer.resetZoom();
|
||
this.renderer.dragOffset = { x: 0, y: 0 };
|
||
this.zoomPercent = 100;
|
||
this.fxZoomed = false;
|
||
},
|
||
toggleMagnifier() {
|
||
if (!this.isFixedLayout) return;
|
||
this.renderer.toggleMagnifier();
|
||
this.magnifierEnabled = this.renderer.zoomMagnifierEnabled;
|
||
},
|
||
setInteractionMode(mode: string) {
|
||
if (!this.isFixedLayout || !this.isPDF) return;
|
||
// Comic-treated PDFs have no text selection: pan only.
|
||
if (this.treatAsComic) return;
|
||
if (mode !== "select" && mode !== "pan" && mode !== "text") return;
|
||
this.renderer.setAttribute("interaction-mode", mode);
|
||
this.interactionMode = mode;
|
||
saveSettings({ pdf_interaction_mode: mode });
|
||
},
|
||
// Reset pan offsets while keeping the current zoom level.
|
||
recenterView() {
|
||
if (!this.isFixedLayout || !this.renderer) return;
|
||
this.renderer.recenter?.();
|
||
this.renderer.dragOffset = { x: 0, y: 0 };
|
||
},
|
||
// ----- fixed-layout display filters -----
|
||
// One CSS var drives everything: ::part(filter) on the foliate-view
|
||
// iframes (comics/PDFs) and the webtoon page images alike.
|
||
applyFxFilter(save = false) {
|
||
const viewport = document.getElementById("reader-viewport");
|
||
if (!viewport) return;
|
||
const parts = [`brightness(${this.fxBrightness})`, `contrast(${this.fxContrast})`];
|
||
if (this.fxInvert) parts.push("invert(1)");
|
||
viewport.style.setProperty("--fx-filter", parts.join(" "));
|
||
if (save) {
|
||
saveSettings({
|
||
fx_brightness: this.fxBrightness,
|
||
fx_contrast: this.fxContrast,
|
||
fx_invert: this.fxInvert,
|
||
});
|
||
}
|
||
},
|
||
// Reading flow is chosen before the renderer is created, so toggling
|
||
// reloads the reader (progress restores from the saved page).
|
||
setComicFlow(mode: string) {
|
||
if (mode !== "paged" && mode !== "webtoon") return;
|
||
if (mode === this.comicFlow) return;
|
||
localStorage.setItem(`bookhoard:comic-flow:${this.mediaItemId}`, mode);
|
||
window.location.reload();
|
||
},
|
||
applyFitMode(mode: string) {
|
||
if (!this.isFixedLayout || !this.renderer) return;
|
||
if (mode === "fit-width" || mode === "fit-page") {
|
||
this.renderer.setAttribute("zoom", mode);
|
||
this.renderer.dragOffset = { x: 0, y: 0 };
|
||
this.fxZoomed = false;
|
||
}
|
||
},
|
||
toggleSpread() {
|
||
this.doublePageSpread = !this.doublePageSpread;
|
||
this.applyDoublePageSpread();
|
||
},
|
||
applyDoublePageSpread() {
|
||
if (!this.isFixedLayout || !this.renderer) return;
|
||
this.renderer.setAttribute("spread", this.doublePageSpread ? "auto" : "none");
|
||
saveSettings({ double_page_spread: this.doublePageSpread });
|
||
},
|
||
goLeft() {
|
||
this.view?.goLeft?.();
|
||
// A page turn is reading intent: dismiss the chrome.
|
||
this.hideChromeNow();
|
||
},
|
||
goRight() {
|
||
this.view?.goRight?.();
|
||
this.hideChromeNow();
|
||
},
|
||
nextPage() {
|
||
this.view?.next?.();
|
||
},
|
||
previousPage() {
|
||
this.view?.prev?.();
|
||
},
|
||
goToFraction(value: string) {
|
||
this.view?.goToFraction?.(parseFloat(value));
|
||
},
|
||
toggleTOC() {
|
||
const opening = !this.tocOpen;
|
||
this.closeDrawers();
|
||
this.tocOpen = opening;
|
||
if (opening && this.tocItems.length === 0 && this.book?.toc) {
|
||
this.tocItems = this.flattenTOC(this.book.toc);
|
||
}
|
||
},
|
||
toggleSettings() {
|
||
const opening = !this.settingsOpen;
|
||
this.closeDrawers();
|
||
this.settingsOpen = opening;
|
||
},
|
||
toggleBookmarks() {
|
||
const opening = !this.bookmarksOpen;
|
||
this.closeDrawers();
|
||
this.bookmarksOpen = opening;
|
||
},
|
||
toggleSearch() {
|
||
// Reflowable books use foliate's DOM search; PDFs use extracted text.
|
||
// Comics have no text at all — and comic-treated PDFs (manga scans)
|
||
// opt out of search to match comic treatment: bookmarks only.
|
||
if ((this.isFixedLayout && !this.isPDF) || this.treatAsComic) return;
|
||
const opening = !this.searchOpen;
|
||
this.closeDrawers();
|
||
this.searchOpen = opening;
|
||
if (opening) {
|
||
(this as any).$nextTick(() => (this as any).$refs.searchInput?.focus());
|
||
}
|
||
},
|
||
toggleTools() {
|
||
this.toolsOpen = !this.toolsOpen;
|
||
},
|
||
toggleHelp() {
|
||
this.helpOpen = !this.helpOpen;
|
||
},
|
||
// Fullscreen: the Fullscreen API on desktop/Android, with iOS's
|
||
// API-less browser falling back to the soft visual mode only (margins
|
||
// adopt the fullscreen style while the browser chrome stays). The
|
||
// reader chrome keeps its own env(safe-area-inset-*) clearances, and
|
||
// the foliate paginator — whose closed shadow root can only be told
|
||
// through its observed attributes — gets a comfortable 'margin' with a
|
||
// safe-area floor, so full-window text never hugs the display edges
|
||
// nor runs under the camera.
|
||
toggleFullscreen() {
|
||
const doc = document as any;
|
||
const realActive = !!doc.fullscreenElement || !!doc.webkitFullscreenElement;
|
||
if (realActive || this.softFullscreen) this._exitFullscreenMode();
|
||
else this._enterFullscreenMode();
|
||
},
|
||
_enterFullscreenMode() {
|
||
this.softFullscreen = true;
|
||
this.isFullscreen = true;
|
||
this._applyFullscreenLayout(true);
|
||
const root = document.documentElement as any;
|
||
if (typeof root.requestFullscreen === "function") {
|
||
root.requestFullscreen({ navigationUI: "hide" }).catch(() => {
|
||
/* denied or interrupted; the visual mode still holds */
|
||
});
|
||
} else if (typeof root.webkitRequestFullscreen === "function") {
|
||
root.webkitRequestFullscreen();
|
||
}
|
||
},
|
||
_exitFullscreenMode() {
|
||
this.softFullscreen = false;
|
||
this.isFullscreen = false;
|
||
this._applyFullscreenLayout(false);
|
||
const doc = document as any;
|
||
if (doc.fullscreenElement && typeof doc.exitFullscreen === "function") {
|
||
doc.exitFullscreen().catch(() => {
|
||
/* ignore */
|
||
});
|
||
} else if (doc.webkitFullscreenElement && typeof doc.webkitExitFullscreen === "function") {
|
||
doc.webkitExitFullscreen();
|
||
}
|
||
},
|
||
// Fullscreen text margins: the paginator yields only through its
|
||
// observed 'margin' attribute (a raw px value for its closed root).
|
||
// While fullscreen, floor the comfortable ratio by the measured env
|
||
// insets — Android reports true cutout insets in fullscreen, and iOS
|
||
// letterboxes are already env-padded chrome, so portrait phones keep
|
||
// text clear of the camera band. Fixed-layout renderers (fxl,
|
||
// webtoon) pack pages edge-to-edge by design and never set it.
|
||
_applyFullscreenLayout(active: boolean) {
|
||
const renderer = (this.view as any)?.renderer;
|
||
if (!renderer || this.isFixedLayout) return;
|
||
if (!active) {
|
||
renderer.setAttribute("margin", "48px");
|
||
} else {
|
||
let insets = 0;
|
||
// env() is not readable in JS: measure the resolved values off the
|
||
// chrome, whose padding IS the env inset.
|
||
for (const [id, edge] of [
|
||
["reader-topbar", "paddingTop"],
|
||
["reader-bottombar", "paddingBottom"],
|
||
] as const) {
|
||
try {
|
||
const el = document.getElementById(id);
|
||
const v = el ? parseFloat(getComputedStyle(el)[edge]) || 0 : 0;
|
||
if (v > insets) insets = v;
|
||
} catch {
|
||
/* keep 0 */
|
||
}
|
||
}
|
||
const comfortable = Math.round(Math.min(window.innerWidth, window.innerHeight) * 0.06);
|
||
const margin = Math.round(Math.max(insets, comfortable, 48));
|
||
renderer.setAttribute("margin", `${margin}px`);
|
||
}
|
||
// Margin/gap only take effect on a (re)layout: force one; the anchor
|
||
// keeps the position (same re-entry path the ResizeObserver uses).
|
||
if (typeof renderer.render === "function") renderer.render();
|
||
},
|
||
// Watch API transitions so exits that happen outside our button
|
||
// (Esc on desktop, browser UI) restore the ordinary margins too.
|
||
setupFullscreen() {
|
||
const doc = document as any;
|
||
const handler = () => {
|
||
const realActive = !!doc.fullscreenElement || !!doc.webkitFullscreenElement;
|
||
if (realActive) {
|
||
this._hadRealFullscreen = true;
|
||
// Insets only settle after the fullscreen relayout (Android
|
||
// switches to true cutout insets): re-apply shortly after entry.
|
||
setTimeout(() => {
|
||
if (this.softFullscreen) this._applyFullscreenLayout(true);
|
||
}, 250);
|
||
} else if (this._hadRealFullscreen) {
|
||
this._hadRealFullscreen = false;
|
||
this.softFullscreen = false;
|
||
this._applyFullscreenLayout(false);
|
||
}
|
||
this.isFullscreen = realActive || this.softFullscreen;
|
||
};
|
||
doc.addEventListener("fullscreenchange", handler);
|
||
doc.addEventListener("webkitfullscreenchange", handler);
|
||
},
|
||
// Desktop edge page-turn zones: fine-pointer devices only, and disabled
|
||
// while a fixed-layout page is zoomed (edge clicks then belong to the
|
||
// content: panning, text selection, highlight editing).
|
||
get edgeZonesActive(): boolean {
|
||
if (!this.pointerFine) return false;
|
||
return !(this.isFixedLayout && this.fxZoomed);
|
||
},
|
||
anyDrawerOpen(): boolean {
|
||
return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
|
||
},
|
||
closeDrawers() {
|
||
this.tocOpen = false;
|
||
this.settingsOpen = false;
|
||
this.bookmarksOpen = false;
|
||
this.searchOpen = false;
|
||
this.toolsOpen = false;
|
||
},
|
||
// ----- in-book search (reflowable) -----
|
||
// view.search() is an async generator: it progressively scans sections,
|
||
// yields {progress} per section and {label, subitems} per section with
|
||
// hits, and draws outline highlights through the overlayer pipeline.
|
||
// A generation counter discards results from superseded searches.
|
||
async runSearch() {
|
||
const q = this.searchQuery.trim();
|
||
if (!q) {
|
||
this.clearSearchResults();
|
||
return;
|
||
}
|
||
this.searchGen++;
|
||
const gen = this.searchGen;
|
||
this.searching = true;
|
||
this.searchProgress = 0;
|
||
this.searchGroups = [];
|
||
this.searchError = "";
|
||
try {
|
||
if (this.isFixedLayout && this.isPDF) {
|
||
await this.runPdfSearch(q, gen);
|
||
} else {
|
||
await this.runEpubSearch(q, gen);
|
||
}
|
||
} catch (e) {
|
||
// Swallowing this silently made a wiring bug look like "no matches".
|
||
console.warn("search failed:", e);
|
||
this.searchError = "Search failed (see browser console)";
|
||
}
|
||
if (gen === this.searchGen) this.searching = false;
|
||
},
|
||
async runEpubSearch(q: string, gen: number) {
|
||
for await (const r of this.view.search({ query: q })) {
|
||
if (gen !== this.searchGen) return;
|
||
if (r === "done") break;
|
||
if (r && typeof r === "object") {
|
||
if (typeof r.progress === "number") {
|
||
this.searchProgress = r.progress;
|
||
continue;
|
||
}
|
||
if (Array.isArray(r.subitems) && r.subitems.length) {
|
||
this.searchGroups.push({
|
||
label: r.label || `Section ${this.searchGroups.length + 1}`,
|
||
items: r.subitems.map((s: any) => ({
|
||
cfi: s.cfi,
|
||
page: null,
|
||
pre: s.excerpt?.pre ?? "",
|
||
match: s.excerpt?.match ?? "",
|
||
post: s.excerpt?.post ?? "",
|
||
})),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async runPdfSearch(q: string, gen: number) {
|
||
// CRITICAL: unwrap Alpine's reactive proxy. `this.book` is a plain
|
||
// object, so reactivity wraps it — and reading `.pdf` through the
|
||
// proxy wraps the PDFDocumentProxy too. pdf.js uses #private fields,
|
||
// so calling getPage() on the proxy throws "cannot read private
|
||
// member", which used to be swallowed as an empty result set.
|
||
const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book;
|
||
const pdf = rawBook?.pdf;
|
||
if (!pdf) {
|
||
this.searchError = "PDF text engine unavailable";
|
||
return;
|
||
}
|
||
if (!this.pdfPagesCache) {
|
||
// Extraction reports progress; it's cached so re-searches are instant.
|
||
this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => {
|
||
if (gen === this.searchGen) this.searchProgress = fraction;
|
||
});
|
||
}
|
||
if (gen !== this.searchGen) return;
|
||
const hits = searchPdfPages(this.pdfPagesCache, q);
|
||
const byPage = new Map<number, typeof hits>();
|
||
for (const hit of hits) {
|
||
const list = byPage.get(hit.page) ?? [];
|
||
list.push(hit);
|
||
byPage.set(hit.page, list);
|
||
}
|
||
const groups: typeof this.searchGroups = [];
|
||
let i = 0;
|
||
for (const [page, list] of byPage) {
|
||
groups.push({
|
||
label: `Page ${page + 1}`,
|
||
items: list.map((hit) => {
|
||
const key = `pdfsearch:${i++}`;
|
||
this.pdfSearchKeys.push(key);
|
||
this.renderer?.addRectAnnotation?.({
|
||
key,
|
||
index: page,
|
||
rects: hit.rects,
|
||
color: "#ffd54f",
|
||
});
|
||
return {
|
||
cfi: "",
|
||
page,
|
||
pre: hit.pre,
|
||
match: hit.match,
|
||
post: hit.post,
|
||
};
|
||
}),
|
||
});
|
||
}
|
||
this.searchGroups = groups;
|
||
},
|
||
clearSearchResults() {
|
||
this.searchGen++;
|
||
this.searching = false;
|
||
this.searchError = "";
|
||
this.searchGroups = [];
|
||
this.searchProgress = 0;
|
||
for (const key of this.pdfSearchKeys) {
|
||
this.renderer?.removeRectAnnotation?.(key);
|
||
}
|
||
this.pdfSearchKeys = [];
|
||
this.view?.clearSearch?.();
|
||
},
|
||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||
if (item.cfi) {
|
||
this.pushBackStack();
|
||
this.view?.goTo?.(item.cfi);
|
||
} else if (item.page != null) {
|
||
this.pushBackStack();
|
||
this.view?.goTo?.(item.page);
|
||
} else return;
|
||
this.closeDrawers();
|
||
},
|
||
// ----- back-to-location stack -----
|
||
// Research pattern: jump somewhere (search hit, footnote link, TOC
|
||
// entry), read, then return. Recorded before every programmatic jump
|
||
// and on every internal link click (footnotes). Ordinary paging never
|
||
// touches the stack.
|
||
pushBackStack() {
|
||
let loc: { cfi?: string; page?: number } | null = null;
|
||
if (this.isFixedLayout) {
|
||
const page = this.renderer?.index;
|
||
if (typeof page === "number" && page >= 0) loc = { page };
|
||
} else {
|
||
const cfi = this.view?.lastLocation?.cfi;
|
||
if (cfi) loc = { cfi };
|
||
}
|
||
if (!loc) return;
|
||
const top = this.backStack[this.backStack.length - 1];
|
||
if (top && top.cfi === loc.cfi && top.page === loc.page) return;
|
||
this.backStack.push(loc);
|
||
if (this.backStack.length > 50) this.backStack.shift();
|
||
},
|
||
goBackToLocation() {
|
||
const loc = this.backStack.pop();
|
||
if (!loc) return;
|
||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||
},
|
||
flattenTOC(items: any[], depth = 0): any[] {
|
||
const result: any[] = [];
|
||
for (const item of items) {
|
||
result.push({ ...item, depth });
|
||
if (item.subitems?.length) {
|
||
result.push(...this.flattenTOC(item.subitems, depth + 1));
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
goToTOCItem(item: any) {
|
||
if (this.view && item.href) {
|
||
this.pushBackStack();
|
||
this.view.goTo(item.href);
|
||
this.tocOpen = false;
|
||
}
|
||
},
|
||
// ----- page thumbnails (fixed-layout: PDF via pdf.js, comics via the
|
||
// page image blobs). Lazy-rendered with an IntersectionObserver so only
|
||
// visible cells pay extraction/render cost. -----
|
||
get currentPageIndex(): number {
|
||
return typeof this.renderer?.index === "number" ? this.renderer.index : -1;
|
||
},
|
||
initPageThumbs() {
|
||
if (!this.isFixedLayout || !this.book) return;
|
||
if (!this.pageThumbList.length) {
|
||
const sections = (this.book as any).sections ?? [];
|
||
this.pageThumbList = sections.map((_s: any, i: number) => ({ index: i }));
|
||
}
|
||
(this as any).$nextTick(() => {
|
||
const grid = document.getElementById("page-thumb-grid");
|
||
if (!grid || this.thumbObserver) {
|
||
this.resumeThumbObserver();
|
||
return;
|
||
}
|
||
const root = grid.closest(".reader-drawer-body");
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue;
|
||
io.unobserve(entry.target);
|
||
const page = parseInt((entry.target as HTMLElement).dataset.page ?? "", 10);
|
||
if (!isNaN(page)) this.renderPageThumb(page, entry.target as HTMLElement);
|
||
}
|
||
},
|
||
{ root: root instanceof Element ? root : null, rootMargin: "200px" },
|
||
);
|
||
this.thumbObserver = io;
|
||
for (const el of Array.from(grid.querySelectorAll<HTMLElement>(".reader-thumb-img"))) {
|
||
io.observe(el);
|
||
}
|
||
this.scrollToCurrentThumb();
|
||
});
|
||
},
|
||
resumeThumbObserver() {
|
||
const grid = document.getElementById("page-thumb-grid");
|
||
if (grid && this.thumbObserver) {
|
||
for (const el of Array.from(
|
||
grid.querySelectorAll<HTMLElement>(".reader-thumb-img:not([data-done])"),
|
||
)) {
|
||
this.thumbObserver.observe(el);
|
||
}
|
||
}
|
||
this.scrollToCurrentThumb();
|
||
},
|
||
scrollToCurrentThumb() {
|
||
const cur = document.querySelector("#page-thumb-grid .reader-thumb.active");
|
||
cur?.scrollIntoView({ block: "center" });
|
||
},
|
||
async renderPageThumb(page: number, container: HTMLElement) {
|
||
if (container.dataset.done) return;
|
||
container.dataset.done = "1";
|
||
try {
|
||
const cached = thumbCache.get(page);
|
||
if (cached) {
|
||
container.appendChild(cached);
|
||
return;
|
||
}
|
||
const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book;
|
||
if (this.isPDF && rawBook?.pdf) {
|
||
const pdfPage = await rawBook.pdf.getPage(page + 1);
|
||
const base = pdfPage.getViewport({ scale: 1 });
|
||
const scale = 110 / base.width;
|
||
const viewport = pdfPage.getViewport({ scale });
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.ceil(viewport.width);
|
||
canvas.height = Math.ceil(viewport.height);
|
||
await pdfPage.render({
|
||
canvasContext: canvas.getContext("2d")!,
|
||
viewport,
|
||
}).promise;
|
||
thumbCache.set(page, canvas);
|
||
container.appendChild(canvas);
|
||
} else {
|
||
// Comic: section.load() yields a page-document blob URL; extract
|
||
// the embedded image URL, draw it small, then free the full-size
|
||
// blob (unload) so thumbnailing doesn't hoard page images.
|
||
const section = rawBook?.sections?.[page];
|
||
const url = await section?.load?.();
|
||
if (!url) return;
|
||
const html = await (await fetch(url)).text();
|
||
const m = html.match(/src="(blob:[^"]+)"/);
|
||
if (!m) return;
|
||
const img = new Image();
|
||
await new Promise<void>((res, rej) => {
|
||
img.onload = () => res();
|
||
img.onerror = () => rej(new Error("img load"));
|
||
img.src = m[1];
|
||
});
|
||
const scale = 110 / (img.naturalWidth || 1);
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 110;
|
||
canvas.height = Math.round((img.naturalHeight || 150) * scale);
|
||
canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
thumbCache.set(page, canvas);
|
||
container.appendChild(canvas);
|
||
// Free the full-size page blob unless it's the page on screen
|
||
// (revoking a URL an iframe already loaded is harmless, but the
|
||
// current page may be re-requested on re-render).
|
||
if (this.renderer?.index !== page) section.unload?.();
|
||
}
|
||
} catch (e) {
|
||
delete container.dataset.done;
|
||
console.warn("thumbnail render failed for page", page + 1, e);
|
||
}
|
||
},
|
||
goToPage(index: number) {
|
||
if (!this.view || typeof index !== "number" || index < 0) return;
|
||
this.pushBackStack();
|
||
this.view.goTo(index);
|
||
this.tocOpen = false;
|
||
},
|
||
goToBookmark(item: { cfi: string; page: number | null }) {
|
||
if (!this.view) return;
|
||
if (item.cfi) {
|
||
this.pushBackStack();
|
||
this.view.goTo(item.cfi);
|
||
} else if (item.page != null && item.page > 0) {
|
||
this.pushBackStack();
|
||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||
this.view.goTo(item.page - 1);
|
||
} else {
|
||
return;
|
||
}
|
||
this.bookmarksOpen = false;
|
||
},
|
||
applyTheme() {
|
||
const viewport = document.getElementById("reader-viewport")!;
|
||
const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
|
||
const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light;
|
||
viewport.style.backgroundColor = themeColors.bg;
|
||
this.applyStyles();
|
||
saveSettings({
|
||
reading_theme: this.readingTheme,
|
||
reading_mode: this.readingMode,
|
||
});
|
||
},
|
||
toggleReadingMode() {
|
||
this.readingMode = this.readingMode === "dark" ? "light" : "dark";
|
||
this.applyTheme();
|
||
},
|
||
// Popover/toolbar handlers (CSP-safe: single method calls from templ).
|
||
toggleFxInvert() {
|
||
this.fxInvert = !this.fxInvert;
|
||
this.applyFxFilter(true);
|
||
},
|
||
openPageThumbs() {
|
||
this.tocTab = "pages";
|
||
this.initPageThumbs();
|
||
},
|
||
submitNote() {
|
||
this.addNote(this.newNoteText);
|
||
this.newNoteText = "";
|
||
},
|
||
clearSearch() {
|
||
this.searchQuery = "";
|
||
this.clearSearchResults();
|
||
},
|
||
pickReadingTheme(value: string) {
|
||
this.readingTheme = value;
|
||
this.applyTheme();
|
||
},
|
||
noteEditHasText(note: { editText?: string }): boolean {
|
||
return String(note.editText ?? "").trim().length > 0;
|
||
},
|
||
searchProgressLabel(): string {
|
||
return `Searching… ${Math.round(this.searchProgress * 100)}%`;
|
||
},
|
||
brightnessLabel(): string {
|
||
return `${Math.round(this.fxBrightness * 100)}%`;
|
||
},
|
||
contrastLabel(): string {
|
||
return `${Math.round(this.fxContrast * 100)}%`;
|
||
},
|
||
detectChromeDarkMode(): boolean {
|
||
const darkChromeThemes = [
|
||
"theme-tokyo-night",
|
||
"theme-dracula",
|
||
"theme-nord",
|
||
"theme-monokai",
|
||
"theme-one-dark",
|
||
"theme-material",
|
||
"theme-catppuccin",
|
||
];
|
||
return darkChromeThemes.some((t) => document.body.classList.contains(t));
|
||
},
|
||
applyFont() {
|
||
this.applyStyles();
|
||
saveSettings({
|
||
reading_font: this.readingFont,
|
||
font_size: this.fontSize,
|
||
line_height: this.lineHeight,
|
||
});
|
||
},
|
||
applyStyles() {
|
||
if (!this.renderer?.setStyles) return;
|
||
this.renderer.setStyles(this.buildCSS());
|
||
},
|
||
buildCSS() {
|
||
return getCSS({
|
||
fontFamily: this.readingFont,
|
||
fontSize: this.fontSize,
|
||
lineHeight: this.lineHeight,
|
||
justify: this.justify,
|
||
hyphenate: this.hyphenate,
|
||
themeName: this.readingTheme,
|
||
themeMode: this.readingMode,
|
||
});
|
||
},
|
||
restoreDefaults() {
|
||
this.progressMode = "pages";
|
||
this.readingTheme = "light";
|
||
this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
|
||
this.readingFont = "literata";
|
||
this.fontSize = 18;
|
||
this.lineHeight = 1.6;
|
||
this.justify = true;
|
||
this.hyphenate = true;
|
||
this.chromeBehavior = "auto-hide";
|
||
this.applyChromeBehavior();
|
||
this.interactionMode = this.treatAsComic ? "pan" : "select";
|
||
this.setInteractionMode(this.interactionMode);
|
||
this.fxBrightness = 1;
|
||
this.fxContrast = 1;
|
||
this.fxInvert = false;
|
||
this.applyFxFilter(true);
|
||
this.applyTheme();
|
||
this.applyFont();
|
||
},
|
||
async refreshBookmarks() {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!resp.ok) return;
|
||
const rows = await resp.json();
|
||
this.bookmarkItems = (rows as any[]).map((r) => ({
|
||
id: r.id,
|
||
title: r.title ?? "",
|
||
positionLabel: r.position?.String ?? r.position ?? "",
|
||
cfi: r.cfi_position?.String ?? r.cfi_position ?? "",
|
||
page: r.page_number != null ? (r.page_number?.Int32 ?? r.page_number) : null,
|
||
}));
|
||
} catch {
|
||
/* leave the existing list on fetch failure */
|
||
}
|
||
},
|
||
async addBookmark() {
|
||
const token = getToken();
|
||
if (!token || !this.view || !this.mediaItemId) return;
|
||
const location = this.view.lastLocation;
|
||
if (!location) return;
|
||
|
||
const cfi = (!this.isFixedLayout && location.cfi) || "";
|
||
const page = this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 : 0;
|
||
// Auto-label mirrors KOReader's convention ("in <chapter title>")
|
||
// so every client names unnamed bookmarks identically; falls back
|
||
// to a plain "Bookmark" for fixed-layout or TOC-less books.
|
||
const chapter = this.lastRelocateDetail?.tocItem?.label as string | undefined;
|
||
const title = !this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark";
|
||
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
title,
|
||
position: this.isFixedLayout ? `page:${page}` : cfi ? `cfi:${cfi}` : "",
|
||
cfi_position: cfi,
|
||
page_number: page,
|
||
chapter_number: this.chapterNumberForProgress() || 0,
|
||
percentage: location.fraction ?? 0,
|
||
}),
|
||
});
|
||
if (resp.ok) {
|
||
await this.refreshBookmarks();
|
||
showToast(`Bookmark added — ${this.progressText || "current position"}`, "success", 2500);
|
||
}
|
||
} catch {
|
||
/* network failures surface via the toast fetch interceptor */
|
||
}
|
||
},
|
||
async deleteBookmark(id: string) {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${id}`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (resp.ok || resp.status === 204) {
|
||
this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id);
|
||
}
|
||
} catch {
|
||
/* ignore bookmark errors for now */
|
||
}
|
||
},
|
||
startBookmarkRename(bookmark: (typeof this.bookmarkItems)[number]) {
|
||
bookmark.renameOpen = true;
|
||
bookmark.renameText = bookmark.title;
|
||
},
|
||
async renameBookmark(bookmark: (typeof this.bookmarkItems)[number]) {
|
||
const token = getToken();
|
||
const title = (bookmark.renameText ?? "").trim();
|
||
if (!token || !this.mediaItemId || !title) return;
|
||
try {
|
||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`, {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
title,
|
||
notes: "",
|
||
position: bookmark.positionLabel || "",
|
||
}),
|
||
});
|
||
if (resp.ok) {
|
||
bookmark.title = title;
|
||
}
|
||
bookmark.renameOpen = false;
|
||
} catch {
|
||
bookmark.renameOpen = false;
|
||
}
|
||
},
|
||
chapterNumberForProgress(): number {
|
||
const tocItem = this.lastRelocateDetail?.tocItem;
|
||
if (!tocItem?.label) return 0;
|
||
const idx = this.chapterBoundaries.findIndex((b: any) => b.label === tocItem.label);
|
||
return idx === -1 ? 0 : idx;
|
||
},
|
||
formatProgressParts(
|
||
fraction: number,
|
||
location: { current: number; next: number; total: number },
|
||
pageItem: { id: number; label: string; href: string },
|
||
tocItem: FoliateTocItem | null,
|
||
section: { current: number; total: number },
|
||
): { label: string; main: string } {
|
||
const percent = new Intl.NumberFormat("en", { style: "percent" }).format(fraction);
|
||
const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : "";
|
||
switch (this.progressMode) {
|
||
case "percentage":
|
||
return { label: "", main: percent };
|
||
case "chapter": {
|
||
if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
|
||
const totalSections =
|
||
this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0;
|
||
const currentPage = this.renderer?.index ?? 0;
|
||
const boundaryIdx = this.chapterBoundaries.findIndex((b) => b.id === tocItem.id);
|
||
if (boundaryIdx !== -1 && totalSections > 0) {
|
||
const chapterStart = this.chapterBoundaries[boundaryIdx].startPage;
|
||
const nextBoundary = this.chapterBoundaries[boundaryIdx + 1];
|
||
const chapterEnd = nextBoundary ? nextBoundary.startPage : totalSections;
|
||
const chapterPages = Math.max(1, chapterEnd - chapterStart);
|
||
const currentInChapter = Math.max(
|
||
1,
|
||
Math.min(currentPage - chapterStart + 1, chapterPages),
|
||
);
|
||
return {
|
||
label: chapterLabel,
|
||
main: `${currentInChapter} / ${chapterPages}`,
|
||
};
|
||
}
|
||
}
|
||
if (tocItem && this.chapterBoundaries.length > 0) {
|
||
const boundaryIdx = this.chapterBoundaries.findIndex((b) => b.id === tocItem.id);
|
||
if (boundaryIdx !== -1) {
|
||
const currentPage = this.renderer?.page ?? 1;
|
||
const chapterStart = this.chapterBoundaries[boundaryIdx].startPage;
|
||
const nextBoundary = this.chapterBoundaries[boundaryIdx + 1];
|
||
const totalPages = this.renderer?.pages ?? 0;
|
||
const chapterEnd = nextBoundary
|
||
? nextBoundary.startPage
|
||
: totalPages > 2
|
||
? totalPages - 1
|
||
: chapterStart + 1;
|
||
const chapterPages = Math.max(1, chapterEnd - chapterStart);
|
||
const currentInChapter = Math.max(
|
||
1,
|
||
Math.min(currentPage - chapterStart + 1, chapterPages),
|
||
);
|
||
return {
|
||
label: chapterLabel,
|
||
main: `${currentInChapter} / ${chapterPages}`,
|
||
};
|
||
}
|
||
}
|
||
if (section && this.sectionFractionsArr.length > 1) {
|
||
const pageInfo = this.getRenderedPageInfo();
|
||
const pageBase = pageInfo?.total ?? location.total;
|
||
const idx = section.current;
|
||
const startFrac = this.sectionFractionsArr[idx] ?? 0;
|
||
const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
|
||
const sectionFrac = endFrac - startFrac;
|
||
if (sectionFrac > 0 && pageBase > 0) {
|
||
const totalInSec = Math.max(1, Math.round(sectionFrac * pageBase));
|
||
const currentInSec = Math.max(1, Math.round((fraction - startFrac) * pageBase));
|
||
const clamped = Math.min(currentInSec, totalInSec);
|
||
return {
|
||
label: chapterLabel,
|
||
main: `${clamped} / ${totalInSec}`,
|
||
};
|
||
}
|
||
}
|
||
if (location.total > 0) {
|
||
return {
|
||
label: "",
|
||
main: `${percent} · ${location.current} / ${location.total}`,
|
||
};
|
||
}
|
||
return { label: "", main: percent };
|
||
}
|
||
case "time-left": {
|
||
if (this.readingSpeedPpm > 0 && location.total > 0) {
|
||
const remaining = location.total - location.current;
|
||
const mins = Math.ceil(remaining / this.readingSpeedPpm);
|
||
if (mins >= 60) {
|
||
const hrs = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
return { label: "", main: `${percent} · ~${hrs}h ${m}m left` };
|
||
}
|
||
return { label: "", main: `${percent} · ~${mins} min left` };
|
||
}
|
||
return { label: "", main: `${percent} · ~-- min left` };
|
||
}
|
||
default: {
|
||
if (this.isFixedLayout) {
|
||
const pageInfo = this.getRenderedPageInfo();
|
||
if (pageInfo) {
|
||
return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` };
|
||
}
|
||
}
|
||
if (pageItem) {
|
||
return { label: "", main: `${percent} · Page ${pageItem.label}` };
|
||
}
|
||
const pageInfoReflow = this.getRenderedPageInfo();
|
||
if (pageInfoReflow) {
|
||
return {
|
||
label: "",
|
||
main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`,
|
||
};
|
||
}
|
||
return {
|
||
label: "",
|
||
main: `${percent} · ${location.current} / ${location.total}`,
|
||
};
|
||
}
|
||
}
|
||
},
|
||
// The reflowable and fixed-layout bottom rows each have their own slider
|
||
// (only one is displayed); helpers below target whichever exists.
|
||
progressSliders(): HTMLInputElement[] {
|
||
const ids = ["progress-slider", "progress-slider-fx", "progress-slider-fx-compact"];
|
||
return ids
|
||
.map((id) => document.getElementById(id) as HTMLInputElement | null)
|
||
.filter((el): el is HTMLInputElement => !!el);
|
||
},
|
||
setProgress(parts: { label: string; main: string }, sliderTitle?: string) {
|
||
this.progressLabel = parts.label;
|
||
this.progressMain = parts.main;
|
||
this.progressText = parts.label + parts.main;
|
||
for (const slider of this.progressSliders()) {
|
||
slider.title = sliderTitle ?? this.progressText;
|
||
}
|
||
},
|
||
cycleProgressMode() {
|
||
const modes = ["pages", "chapter", "percentage", "time-left"];
|
||
const idx = modes.indexOf(this.progressMode);
|
||
this.progressMode = modes[(idx + 1) % modes.length];
|
||
this.applyProgressMode();
|
||
if (this.lastRelocateDetail) {
|
||
const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail;
|
||
this.setProgress(this.formatProgressParts(fraction, location, pageItem, tocItem, section));
|
||
}
|
||
},
|
||
async fetchReadingSpeed() {
|
||
const token = getToken();
|
||
if (!token || !this.mediaItemId) return;
|
||
try {
|
||
const resp = await fetch(`/readers/${this.mediaItemId}/reading-speed`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
this.readingSpeedPpm = data.pages_per_minute || 0;
|
||
}
|
||
} catch {
|
||
this.readingSpeedPpm = 0;
|
||
}
|
||
},
|
||
applyProgressMode() {
|
||
saveSettings({ progress_mode: this.progressMode as any });
|
||
if (this.lastRelocateDetail) {
|
||
const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail;
|
||
this.setProgress(this.formatProgressParts(fraction, location, pageItem, tocItem, section));
|
||
}
|
||
},
|
||
computeFixedLayoutChapterBoundaries() {
|
||
if (!this.book?.toc || !this.view) return;
|
||
const flat = this.flattenTOC(this.book.toc);
|
||
for (const item of flat) {
|
||
try {
|
||
const resolved = this.view.resolveNavigation(item.href);
|
||
if (resolved?.index != null) {
|
||
this.chapterBoundaries.push({
|
||
id: item.id,
|
||
label: item.label,
|
||
startPage: resolved.index,
|
||
});
|
||
}
|
||
} catch {
|
||
// skip unresolvable anchors
|
||
}
|
||
}
|
||
this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage);
|
||
},
|
||
computeChapterPageBoundaries(doc: Document) {
|
||
this.chapterBoundaries = [];
|
||
if (!this.book?.toc) return;
|
||
const size = this.renderer?.size;
|
||
if (!size) return;
|
||
const flat = this.flattenTOC(this.book.toc);
|
||
for (const item of flat) {
|
||
const fragment = item.href?.split("#")[1];
|
||
if (!fragment) continue;
|
||
try {
|
||
const el = this.book.getTOCFragment?.(doc, fragment);
|
||
if (!el) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
const page = Math.floor(rect.left / size) + 1;
|
||
this.chapterBoundaries.push({
|
||
id: item.id,
|
||
label: item.label,
|
||
startPage: page,
|
||
});
|
||
} catch {
|
||
// skip unresolvable anchors
|
||
}
|
||
}
|
||
this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage);
|
||
},
|
||
getRenderedPageInfo(): { total: number; current: number } | null {
|
||
if (this.isFixedLayout) {
|
||
const total = this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0;
|
||
if (total === 0) return null;
|
||
const index = this.renderer?.index ?? 0;
|
||
return { total, current: index + 1 };
|
||
}
|
||
const pages = this.renderer?.pages;
|
||
if (pages != null && pages > 2) {
|
||
return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 };
|
||
}
|
||
return null;
|
||
},
|
||
progressTooltip(): string {
|
||
const labels: Record<string, string> = {
|
||
pages: "Pages",
|
||
chapter: "Chapter",
|
||
percentage: "Percentage",
|
||
"time-left": "Time Left",
|
||
};
|
||
const label = labels[this.progressMode] ?? "Pages";
|
||
if (this.progressMode === "chapter" && this.chapterBoundaries.length === 0) {
|
||
return `${label} · no chapter data · Click to switch`;
|
||
}
|
||
return `${label} · Click to switch`;
|
||
},
|
||
handleKeydown(event: KeyboardEvent) {
|
||
const k = event.key;
|
||
// Never hijack keys while the user is typing in a form control: the
|
||
// field must receive h/l page turns, +/− zoom, and caret arrows.
|
||
// Escape stays live so popovers/drawers can still be dismissed from
|
||
// the keyboard even mid-note.
|
||
const t = event.target as HTMLElement | null;
|
||
const isProgressSlider =
|
||
t instanceof HTMLInputElement && t.type === "range" && t.id.startsWith("progress-slider");
|
||
const typing =
|
||
t?.tagName === "INPUT" ||
|
||
t?.tagName === "SELECT" ||
|
||
t?.tagName === "TEXTAREA" ||
|
||
!!t?.isContentEditable;
|
||
// NOTE: no chrome poke here — keyboard page turns are reading
|
||
// actions and must leave the chrome alone (handoff item 7).
|
||
if (typing && k !== "Escape") {
|
||
// A focused progress range swallows arrows as ~1% book-jumps —
|
||
// with key repeat, holding an arrow crosses the entire book in
|
||
// seconds and the debounced saves persist every wild landing
|
||
// (2026-10-21 incident). Arrows are page turns everywhere in the
|
||
// reader: release the slider and fall through to the turn
|
||
// handlers. Every other key still belongs to the control.
|
||
if (!(isProgressSlider && (k === "ArrowLeft" || k === "ArrowRight"))) return;
|
||
event.preventDefault();
|
||
t.blur();
|
||
}
|
||
if (k === "ArrowLeft" || k === "h") {
|
||
if (event.altKey) {
|
||
event.preventDefault();
|
||
this.goBackToLocation();
|
||
} else this.goLeft();
|
||
} else if (k === "ArrowRight" || k === "l") this.goRight();
|
||
else if (k === "+" || k === "=") this.zoomIn();
|
||
else if (k === "-" || k === "_") this.zoomOut();
|
||
else if (k === "0") this.resetZoom();
|
||
else if (k === "Escape") {
|
||
if (this.helpOpen) {
|
||
this.helpOpen = false;
|
||
} else if (this.selectionPopover.open) {
|
||
this.hideSelectionPopover();
|
||
} else if (this.toolsOpen) {
|
||
this.toolsOpen = false;
|
||
} else if (this.anyDrawerOpen()) {
|
||
this.closeDrawers();
|
||
} else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
|
||
this.toggleMagnifier();
|
||
} else {
|
||
// Toggle chrome without a fresh auto-hide timer.
|
||
this.chromeVisible = !this.chromeVisible;
|
||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||
}
|
||
} else if (k === "F1") {
|
||
event.preventDefault();
|
||
this.toggleHelp();
|
||
} else {
|
||
if (k === "t") this.toggleTOC();
|
||
else if (k === "s") this.toggleSettings();
|
||
else if (k === "b") this.addBookmark();
|
||
else if (k === "f") {
|
||
// Ctrl/Cmd+F stays the browser's find-in-page.
|
||
if (!event.ctrlKey && !event.metaKey && !event.altKey) this.toggleFullscreen();
|
||
} else if (k === "a") {
|
||
if (!event.ctrlKey && !event.metaKey && !event.altKey) this.toggleBookmarks();
|
||
} else if (k === "?") this.toggleHelp();
|
||
else if (k === "/") {
|
||
event.preventDefault();
|
||
this.toggleSearch();
|
||
}
|
||
}
|
||
},
|
||
}));
|
||
});
|
||
|
||
Alpine.start();
|