feat(reader): comic treatment for fixed-layout items in comics/manga libraries

PDFs shelved in comics or manga libraries (scanned manga, official manga
PDFs with text layers) now get comic reader treatment: bookmarks only —
no text-selection highlights, no annotations, no in-book search. Items
in ebooks libraries are completely unaffected, and EPUBs keep their
existing behavior everywhere.

- handlers: new exported ShouldTreatAsComic(libraryType, formatGroup) —
  true for fixed_layout/comic_archive in manga/comics libraries; the
  reader JSON API also exposes it as treat_as_comic
- router: the SSR reader page fetches the library type and passes
  TreatAsComic through ReaderMetadata into the init config
- reader: when treatAsComic is set, the PDF textLayer selection listener
  (mouse and touch paths) never attaches so no highlight popover can
  open; pointer mode is forced to pan (Smart/Text segment hidden);
  search button and toggleSearch are disabled; previously-created PDF
  highlights stop rendering
- bookmarks are unchanged (already page-index based for fixed layout),
  and device sync / OPDS / format classification are untouched since
  format_group stays fixed_layout
This commit is contained in:
2026-09-17 14:36:58 -04:00
parent b3a429d7e1
commit 80e5d5b0d1
6 changed files with 158 additions and 108 deletions
+33 -7
View File
@@ -398,6 +398,10 @@ document.addEventListener("alpine:init", () => {
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,
@@ -588,8 +592,10 @@ document.addEventListener("alpine:init", () => {
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 —
@@ -724,7 +730,11 @@ document.addEventListener("alpine:init", () => {
// drags, so the popover's pointerup trigger never fires for
// them; a selection that has been stable for a moment is the
// settled gesture — open the popover for it.
if (this.isFixedLayout && doc.querySelector(".textLayer"))
if (
this.isFixedLayout &&
!this.treatAsComic &&
doc.querySelector(".textLayer")
)
this.schedulePDFSelectionPopover(doc, index);
else if (!this.isFixedLayout)
this.scheduleSelectionPopover(doc, index);
@@ -785,7 +795,12 @@ document.addEventListener("alpine:init", () => {
// 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.
if (this.isFixedLayout && doc.querySelector(".textLayer")) {
// Comic-treated PDFs never attach it: no highlights, like comics.
if (
this.isFixedLayout &&
!this.treatAsComic &&
doc.querySelector(".textLayer")
) {
doc.addEventListener(
"pointerup",
(e: PointerEvent) =>
@@ -939,6 +954,9 @@ document.addEventListener("alpine:init", () => {
// 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;
@@ -1607,7 +1625,12 @@ document.addEventListener("alpine:init", () => {
const rows = await hlResp.json();
this.highlightItems = (rows as any[])
.map((r) => this.mapHighlightRow(r))
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0);
// 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) {
@@ -2143,6 +2166,8 @@ document.addEventListener("alpine:init", () => {
},
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;
@@ -2237,8 +2262,9 @@ document.addEventListener("alpine:init", () => {
},
toggleSearch() {
// Reflowable books use foliate's DOM search; PDFs use extracted text.
// Comics have no text at all.
if (this.isFixedLayout && !this.isPDF) return;
// 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;
@@ -2646,8 +2672,8 @@ document.addEventListener("alpine:init", () => {
this.hyphenate = true;
this.chromeBehavior = "auto-hide";
this.applyChromeBehavior();
this.interactionMode = "select";
this.setInteractionMode("select");
this.interactionMode = this.treatAsComic ? "pan" : "select";
this.setInteractionMode(this.interactionMode);
this.fxBrightness = 1;
this.fxContrast = 1;
this.fxInvert = false;