Files
bookhoard/web/src/reader/reader.ts
T
John O'Keefe afbf1029a9
Release / build-and-push (push) Successful in 3m12s
feat(web): hold a Screen Wake Lock while reading or listening
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.
2026-10-26 13:59:37 -04:00

4143 lines
160 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();