User report (desktop): the endpoint handles wouldn't move. renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on every preview frame — the first drag move triggered a repaint that DESTROYED the dot being dragged. A removed element loses pointer capture, so real mouse/touch drags died silently after one pixel; the pointerup never reached the detached dot either, so nothing saved and the session preview reverted — the same mechanism behind the earlier "resets to the original selection" report. CDP tests masked it because synthetic events dispatched directly at the detached node still ran its listeners. The two dots are now created once per session and every later call only repositions them (style updates move nothing the listeners live on). Grip-era code had this reuse pattern; the endpoint rewrite regressed it. Real-touch verified on the emulator (adb-injected drag): the handle tracks, the release saves a multi-line extent with clean recomputed text (3 rects, PUT applied).
4043 lines
149 KiB
TypeScript
4043 lines
149 KiB
TypeScript
import "foliate-js/view.js";
|
||
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
|
||
import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
|
||
import { Alpine } from "../alpine";
|
||
import { loadSettings, saveSettings } from "./settings-manager";
|
||
import { getToken } from "../storage";
|
||
import { showToast } from "../toast";
|
||
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
|
||
import {
|
||
extractPdfPages,
|
||
searchPdfPages,
|
||
type PdfPageText,
|
||
} from "./pdf-search";
|
||
|
||
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 (_e) {
|
||
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,
|
||
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,
|
||
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;
|
||
}) {
|
||
this.mediaItemId = config.mediaItemId;
|
||
this.treatAsComic = config.treatAsComic;
|
||
// 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),
|
||
);
|
||
// Pointer activity inside page iframes should keep the chrome awake
|
||
// (fixed-layout pages and EPUB sections are separate documents).
|
||
doc.addEventListener("pointermove", () => this.pokeChrome(), {
|
||
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) => {
|
||
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();
|
||
},
|
||
// Chrome visibility: bars overlay the edge-to-edge reading surface.
|
||
// 'auto-hide' keeps them up while the pointer is active and fades them
|
||
// out after a quiet period; 'always-visible' pins them.
|
||
setupChrome() {
|
||
document.addEventListener("pointermove", () => this.pokeChrome(), {
|
||
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;
|
||
}
|
||
this.pokeChrome();
|
||
},
|
||
pokeChrome() {
|
||
// Never raise the chrome while a touch selection is live — the
|
||
// selection popover owns the screen until the selection is
|
||
// consumed or dismissed.
|
||
if (this.anySelection) return;
|
||
this.chromeVisible = true;
|
||
if (this.chromeBehavior !== "auto-hide") return;
|
||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||
this.hideTimer = setTimeout(() => {
|
||
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
|
||
this.chromeVisible = false;
|
||
}, 2500);
|
||
},
|
||
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;
|
||
if (moved || longPressed || Date.now() - downT > 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 pages by swipe only (the paginator's pan + snap); a tap
|
||
// anywhere just toggles the chrome. The edge zones remain for
|
||
// mouse-driven taps.
|
||
if (window.matchMedia("(pointer: coarse)").matches) {
|
||
this.toggleChromeManually();
|
||
return;
|
||
}
|
||
const zone = this.tapZoneSize / 100;
|
||
if (relX < zone) this.goLeft();
|
||
else if (relX > 1 - zone) this.goRight();
|
||
else this.toggleChromeManually();
|
||
}, 280);
|
||
},
|
||
toggleChromeManually() {
|
||
if (this.chromeVisible) {
|
||
this.chromeVisible = false;
|
||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||
} else {
|
||
this.pokeChrome();
|
||
}
|
||
},
|
||
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;
|
||
// the popover is ~240px wide, so the fixed margins must cover half
|
||
// of it). NOTE: do not mutate the position after opening — touching
|
||
// the style mid-enter-transition leaves the popover invisible to
|
||
// compositor hit-testing (taps fall through to the content iframe).
|
||
const w = window.innerWidth;
|
||
const h = window.innerHeight;
|
||
p.x = Math.min(Math.max(opts.x, 130), w - 130);
|
||
p.y = Math.min(Math.max(opts.y, 60), h - 60);
|
||
p.open = true;
|
||
},
|
||
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, doc, 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 (rect-edge, v2: endpoints) -----
|
||
/** 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. The canvas's
|
||
* post-transform rect IS the visible page (documentElement is dpr×
|
||
* too small — pdf.js scales <html> by 1/devicePixelRatio); iframe
|
||
* client px map to host px by the frame's own rect ratios (the
|
||
* fork's convertPoint math). Computed fresh per call so zoom/pan
|
||
* mid-session never works from stale geometry. */
|
||
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 canvas =
|
||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||
(doc.querySelector("img") as HTMLElement);
|
||
const dr = canvas?.getBoundingClientRect();
|
||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||
const fr = frame?.getBoundingClientRect();
|
||
if (!dr || !dr.width || !dr.height || !fr) return null;
|
||
const scaleX = fr.width / (doc.documentElement.clientWidth || 1);
|
||
const scaleY = fr.height / (doc.documentElement.clientHeight || 1);
|
||
const left = fr.left + dr.left * scaleX;
|
||
const top = fr.top + dr.top * scaleY;
|
||
const width = dr.width * scaleX;
|
||
const height = dr.height * scaleY;
|
||
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,
|
||
};
|
||
},
|
||
/** Doc-local px for caretRangeFromPoint probes. */
|
||
toDoc(fx: number, fy: number) {
|
||
return { x: dr.left + fx * dr.width, y: dr.top + fy * dr.height };
|
||
},
|
||
};
|
||
},
|
||
/** Character glyph boxes (page fractions) whose center sits inside a
|
||
* y-band of the text layer, reading order. One Range per character
|
||
* over each span's text node — caret-granularity snap targets, the
|
||
* same vocabulary the reflowable adjust snaps with. Spans outside
|
||
* the band are prefiltered by their own box to keep the per-char
|
||
* range work line-sized. */
|
||
pdfCharBoxes(
|
||
doc: Document,
|
||
bandTop: number,
|
||
bandBottom: number,
|
||
): { 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 }[] =
|
||
[];
|
||
// 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
|
||
// from the originals (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
|
||
// (including tight pairs like "il") never reach that.
|
||
const range = doc.createRange();
|
||
const spans = doc.querySelectorAll(".textLayer span");
|
||
spans.forEach((span) => {
|
||
const sr = span.getBoundingClientRect();
|
||
const sTop = (sr.top - dr.top) / dr.height;
|
||
const sBottom = (sr.bottom - dr.top) / dr.height;
|
||
if (sBottom < bandTop || sTop > bandBottom) return;
|
||
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 x0 = (r.left - dr.left) / dr.width;
|
||
const x1 = (r.right - dr.left) / dr.width;
|
||
const y0 = (r.top - dr.top) / dr.height;
|
||
const y1 = (r.bottom - dr.top) / dr.height;
|
||
const cy = (y0 + y1) / 2;
|
||
if (cy < bandTop || cy > bandBottom) continue;
|
||
const w = x1 - x0;
|
||
const dup = out.some((k) => {
|
||
if (Math.abs(k.y0 - y0) > (k.y1 - k.y0) * 0.5) return false;
|
||
const kw = k.x1 - k.x0;
|
||
const overlap = Math.min(k.x1, x1) - Math.max(k.x0, x0);
|
||
return overlap > Math.min(kw, w) * 0.5;
|
||
});
|
||
if (dup) continue;
|
||
out.push({ x0, x1, y0, y1, ch: text[i] });
|
||
}
|
||
});
|
||
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,
|
||
* reconstruct the extent's range from the stored rects via caret
|
||
* probes, 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 rects = p.pdfRects;
|
||
const first = rects[0];
|
||
const last = rects[rects.length - 1];
|
||
// Reconstruct the extent's range: carets just inside the first
|
||
// rect's left-middle and the last rect's right-middle.
|
||
const startPt = map.toDoc(first[0] + 0.002, first[1] + first[3] / 2);
|
||
const endPt = map.toDoc(
|
||
last[0] + last[2] - 0.002,
|
||
last[1] + last[3] / 2,
|
||
);
|
||
const doc = map.doc;
|
||
const sc = doc.caretRangeFromPoint?.(startPt.x, startPt.y) ?? null;
|
||
const ec = doc.caretRangeFromPoint?.(endPt.x, endPt.y) ?? null;
|
||
if (!sc || !ec) return;
|
||
const range = doc.createRange();
|
||
range.setStart(sc.startContainer, sc.startOffset);
|
||
range.setEnd(ec.startContainer, ec.startOffset);
|
||
if (range.collapsed) return;
|
||
this.pdfAdjust = {
|
||
id: p.id,
|
||
index: p.pdfPage,
|
||
page: p.pdfPage,
|
||
color: p.color,
|
||
rects,
|
||
original: p.pdfRects.map((r) => [...r]),
|
||
range,
|
||
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. Reuses the elements so drag-capture survives a
|
||
* repaint. */
|
||
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;
|
||
layer.innerHTML = "";
|
||
const first = ctx.rects[0];
|
||
const last = ctx.rects[ctx.rects.length - 1];
|
||
if (!first || !last) return;
|
||
// Start dot at the first rect's LEFT-MIDDLE, end dot at the last
|
||
// rect's RIGHT-MIDDLE — the range boundary points.
|
||
const pts: Array<{ which: "start" | "end"; fx: number; fy: number }> = [
|
||
{ which: "start", fx: first[0], fy: first[1] + first[3] / 2 },
|
||
{ which: "end", fx: last[0] + last[2], fy: last[1] + last[3] / 2 },
|
||
];
|
||
for (const pt of pts) {
|
||
// Create each dot ONCE per session; every later call only
|
||
// REPOSITIONS the existing element. An innerHTML rebuild here
|
||
// destroys the dot mid-drag — it loses pointer capture and the
|
||
// drag dies silently ("handles not moving", user report).
|
||
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;
|
||
this.pdfAdjustHandleMove(which, e.clientX, e.clientY);
|
||
});
|
||
dot.addEventListener("pointerup", (e) => {
|
||
if (!dot!.dataset.dragging) return;
|
||
delete dot!.dataset.dragging;
|
||
try {
|
||
dot!.releasePointerCapture?.(e.pointerId);
|
||
} catch {}
|
||
this.pdfAdjustRelease();
|
||
});
|
||
dot.addEventListener("pointercancel", () => {
|
||
if (!dot!.dataset.dragging) return;
|
||
delete dot!.dataset.dragging;
|
||
});
|
||
layer.appendChild(dot);
|
||
}
|
||
const host = map.toHost(pt.fx, pt.fy);
|
||
dot.style.left = `${host.x - 13}px`;
|
||
dot.style.top = `${host.y - 13}px`;
|
||
}
|
||
},
|
||
/** Endpoint drag: the finger (host px) re-anchors the grabbed end at
|
||
* the caret under it — character-precise (a trailing period or
|
||
* line-break hyphen can be taken or shed), crossing lines freely —
|
||
* and the rects rebuild from the range's own line boxes. */
|
||
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 pt = map.toDoc(fx, fy);
|
||
const caret = map.doc.caretRangeFromPoint?.(pt.x, pt.y) ?? null;
|
||
if (!caret) return;
|
||
const working = map.doc.createRange();
|
||
try {
|
||
if (which === "start") {
|
||
working.setStart(caret.startContainer, caret.startOffset);
|
||
working.setEnd(ctx.range.endContainer, ctx.range.endOffset);
|
||
} else {
|
||
working.setStart(ctx.range.startContainer, ctx.range.startOffset);
|
||
working.setEnd(caret.startContainer, caret.startOffset);
|
||
}
|
||
} catch {
|
||
return;
|
||
}
|
||
// Handles cannot cross: clamp the moving end at the opposite
|
||
// boundary.
|
||
if (working.collapsed) {
|
||
try {
|
||
if (which === "start")
|
||
working.setStart(ctx.range.endContainer, ctx.range.endOffset);
|
||
else working.setEnd(ctx.range.startContainer, ctx.range.startOffset);
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
ctx.range = working;
|
||
ctx.rects = this.rectsForRange(map.doc, working);
|
||
this.schedulePdfAdjustPreview();
|
||
},
|
||
/** Line boxes of a range (page fractions), duplicate copy boxes
|
||
* collapsed: pdf.js re-render span sets duplicate glyphs at
|
||
* sub-pixel shifts, so a naive getClientRects sweep returns the
|
||
* same line twice — harmless to paint, noise in the payload. */
|
||
rectsForRange(doc: Document, range: Range): number[][] {
|
||
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: number[][] = [];
|
||
for (const r of Array.from(range.getClientRects())) {
|
||
const box = [
|
||
(r.left - dr.left) / dr.width,
|
||
(r.top - dr.top) / dr.height,
|
||
r.width / dr.width,
|
||
r.height / dr.height,
|
||
];
|
||
if (box[2] <= 0 || box[3] <= 0) continue;
|
||
const cx = box[0] + box[2] / 2;
|
||
const cy = box[1] + box[3] / 2;
|
||
const dup = out.some(
|
||
(k) =>
|
||
cx >= k[0] - 0.003 &&
|
||
cx <= k[0] + k[2] + 0.003 &&
|
||
cy >= k[1] - 0.003 &&
|
||
cy <= k[1] + k[3] + 0.003,
|
||
);
|
||
if (!dup) out.push(box);
|
||
}
|
||
return out;
|
||
},
|
||
/** 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 range IS the extent — serialize its rects, recompute
|
||
* the selection text from the deduped glyph boxes, and save through
|
||
* the popover's own PUT path. */
|
||
async pdfAdjustRelease() {
|
||
const ctx = this.pdfAdjust;
|
||
if (!ctx) return;
|
||
const map = this.pdfPageMapping(ctx.index);
|
||
const text = map ? this.pdfTextForRects(map.doc, ctx.rects) : "";
|
||
const { id, page, rects } = ctx;
|
||
ctx.committed = true;
|
||
this.cancelPdfAdjust();
|
||
if (!text) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
// Route through the popover state: saveHighlightChanges serializes
|
||
// the anchor from p.pdfRects, PUTs, and re-renders the overlay.
|
||
const p = this.selectionPopover;
|
||
p.id = id;
|
||
p.pdfPage = page;
|
||
p.pdfRects = rects;
|
||
p.text = text;
|
||
await this.saveHighlightChanges();
|
||
},
|
||
/** Reading-order text of the characters whose glyph boxes sit inside
|
||
* any working rect — the adjusted extent's selection text. Lines join
|
||
* with a space and in-line word gaps re-insert the space, matching
|
||
* the creation path's normalized selection text. */
|
||
pdfTextForRects(doc: Document, rects: number[][]) {
|
||
const top = Math.min(...rects.map((r) => r[1]));
|
||
const bottom = Math.max(...rects.map((r) => r[1] + r[3]));
|
||
const chars = this.pdfCharBoxes(doc, top, bottom).filter((c) => {
|
||
const cy = (c.y0 + c.y1) / 2;
|
||
const cx = (c.x0 + c.x1) / 2;
|
||
return rects.some(
|
||
(r) =>
|
||
cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3],
|
||
);
|
||
});
|
||
if (!chars.length) return "";
|
||
const widths = chars.map((c) => c.x1 - c.x0).sort((a, b) => a - b);
|
||
const medW = widths[widths.length >> 1] || 0.001;
|
||
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 > medW * 0.3) text += " ";
|
||
}
|
||
text += c.ch;
|
||
prev = c;
|
||
}
|
||
return text.replace(/\s+/g, " ").trim();
|
||
},
|
||
/** 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 grips could not express). Rects rebuild from
|
||
// the selection's line boxes, text re-reads, saved immediately:
|
||
// the grips' 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 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;
|
||
const t = this.pdfTextForRects(doc, rects);
|
||
if (!t) {
|
||
showToast("Highlights can't be empty", "error");
|
||
return;
|
||
}
|
||
const session = this.pdfAdjust;
|
||
session.committed = true;
|
||
this.cancelPdfAdjust();
|
||
const p = this.selectionPopover;
|
||
p.id = session.id;
|
||
p.pdfPage = session.page;
|
||
p.pdfRects = rects;
|
||
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); multi-node selections just fail
|
||
// resolution harmlessly and fall back to the point CFI.
|
||
toRangeCfi(start: string, end: string, text: string): string {
|
||
if (!start) return end || start;
|
||
if (start.includes(",")) return start; // already a range CFI
|
||
const re =
|
||
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
|
||
const ms = re.exec(start);
|
||
if (!ms) return start;
|
||
const base = ms[1];
|
||
const startLocal = ms[2];
|
||
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
|
||
const utf16len = [...(text ?? "")].reduce(
|
||
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
|
||
0,
|
||
);
|
||
let endLocal = "";
|
||
if (end && !end.includes(",")) {
|
||
const me = re.exec(end);
|
||
if (me && me[1] === base) {
|
||
const endOff = me[3] ? parseInt(me[3], 10) : -1;
|
||
// Degenerate: end resolves to the document start (the old
|
||
// converter fallback) or sits before the start offset.
|
||
const degenerate =
|
||
endOff === 0 ||
|
||
(startOff >= 0 && endOff >= 0 && endOff < startOff);
|
||
if (!degenerate) endLocal = me[2];
|
||
}
|
||
}
|
||
if (!endLocal) {
|
||
if (startOff < 0 || utf16len <= 0) return start; // point CFI
|
||
const cut = startLocal.lastIndexOf(":");
|
||
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
|
||
}
|
||
return `${base},${startLocal},${endLocal})`;
|
||
},
|
||
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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
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 (_e) {
|
||
// 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?.();
|
||
},
|
||
goRight() {
|
||
this.view?.goRight?.();
|
||
},
|
||
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;
|
||
},
|
||
// 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();
|
||
},
|
||
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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
/* 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 (_e) {
|
||
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 (_e) {
|
||
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 typing =
|
||
t?.tagName === "INPUT" ||
|
||
t?.tagName === "SELECT" ||
|
||
t?.tagName === "TEXTAREA" ||
|
||
!!t?.isContentEditable;
|
||
this.pokeChrome();
|
||
if (typing && k !== "Escape") return;
|
||
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 === "a") {
|
||
if (!event.ctrlKey && !event.metaKey && !event.altKey) this.toggleBookmarks();
|
||
}
|
||
else if (k === "?") this.toggleHelp();
|
||
else if (k === "/") {
|
||
event.preventDefault();
|
||
this.toggleSearch();
|
||
}
|
||
}
|
||
},
|
||
}));
|
||
});
|
||
|
||
Alpine.start();
|