From 80e5d5b0d131887c90062e3d1ceeb93ba6b4a237 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 17 Sep 2026 14:36:58 -0400 Subject: [PATCH] feat(reader): comic treatment for fixed-layout items in comics/manga libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/handlers/reader.go | 21 +++++ internal/router/reader.go | 8 ++ templates/reader.templ | 179 +++++++++++++++++------------------- templates/reader_templ.go | 15 +-- templates/types.go | 3 + web/src/reader/reader.ts | 40 ++++++-- 6 files changed, 158 insertions(+), 108 deletions(-) diff --git a/internal/handlers/reader.go b/internal/handlers/reader.go index 229a458..e8ffe73 100644 --- a/internal/handlers/reader.go +++ b/internal/handlers/reader.go @@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error { // Determine if panel detection should be enabled enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup) + treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup) // Get reading progress var progress database.ReadingProgress @@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error { "progress": progress, "bookmarks": bookmarks, "enable_panel_detection": enablePanelDetection, + "treat_as_comic": treatAsComic, "format_group": mediaItem.FormatGroup, "manga_type": mediaItem.MangaType, "reading_direction": mediaItem.ReadingDirection, @@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error { }) } +// ShouldTreatAsComic reports whether a fixed-layout item that lives in a +// comics or manga library should get comic reader treatment: bookmarks +// only — no text-selection highlights, no annotations, no in-book search. +// Comic archives already behave this way; this flag extends it to fixed- +// layout PDFs shelved in comic libraries (scanned or official manga PDFs). +func ShouldTreatAsComic(libraryType string, formatGroup string) bool { + comicLibraries := map[string]bool{ + "manga": true, + "comics": true, + } + + fixedFormats := map[string]bool{ + "fixed_layout": true, + "comic_archive": true, + } + + return comicLibraries[libraryType] && fixedFormats[formatGroup] +} + // shouldEnablePanelDetection determines if panel detection should be enabled // based on library type and format group func shouldEnablePanelDetection(libraryType string, formatGroup string) bool { diff --git a/internal/router/reader.go b/internal/router/reader.go index 0e0d89d..2f73509 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -66,6 +66,13 @@ func registerReaderRoutes(cfg *Config) { if !visible { return renderErrorPage(c, "Access denied", "access_denied") } + // Library type decides comic reader treatment: fixed-layout items + // (e.g. manga PDFs) shelved in comics/manga libraries are paged + // through like comics — bookmarks only, no highlights or search. + libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID) + if err != nil { + return renderErrorPage(c, "Failed to get library info", "library_error") + } // Convert to template types. Reading state is deliberately NOT // fetched or embedded: the reader pulls position, bookmarks, and // annotations from the APIs at open time so the page can never @@ -89,6 +96,7 @@ func registerReaderRoutes(cfg *Config) { FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}), TotalCharacters: mediaItem.TotalCharacters.Int64, EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), + TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup), } // Render template var buf bytes.Buffer diff --git a/templates/reader.templ b/templates/reader.templ index d308407..84b7031 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -16,6 +16,7 @@ func readerInitExpr(metadata ReaderMetadata) string { "formatGroup": metadata.FormatGroup, "readingDirection": metadata.ReadingDirection, "mangaType": metadata.MangaType, + "treatAsComic": metadata.TreatAsComic, } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) @@ -68,9 +69,7 @@ templ Reader(user User, metadata ReaderMetadata) { - @ReaderChrome(metadata) - -
@ReaderTOCDrawer()
-
@ReaderSettingsDrawer()
-
-
@ReaderAnnotationsDrawer()
-
@ReaderSearchDrawer()
-
Settingss
Add bookmarkb
Close menus / toggle chromeEsc
-

Zoom & pan (comics, PDFs)

Zoom in / out / reset+0 or wheel
Move the pagedrag with the mouse
Recenter (keeps zoom)⊙ button
Select text in a PDFSmart mode: drag over text · Shift+drag anywhere · Text mode
-

Highlights

Createselect text → pick a color
Edit / deleteclick the highlight
-

Touch

Page turnswipe, or tap outer edges
Toggle chrometap center
@@ -301,7 +291,7 @@ templ ReaderChrome(metadata ReaderMetadata) {

{ metadata.Title }

- + @@ -344,7 +334,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
- +
@@ -354,92 +344,93 @@ templ ReaderChrome(metadata ReaderMetadata) {
+ +
+ + + +
+ + -
- - -
- -
- - - - - - - - -
- -
- - - -
- - - -
-
- + +
+
+ +
+
- -
0/0
Zoom
Fit
Page position
Magnifier
Night mode
Pointer
Double page
Contents
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
0/0
Zoom
Fit
Page position
Magnifier
Night mode
Pointer
Double page
Contents
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -229,7 +230,7 @@ func drawerHeader(title string) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 561, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 552, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/templates/types.go b/templates/types.go index afdda90..e315eb4 100644 --- a/templates/types.go +++ b/templates/types.go @@ -243,6 +243,9 @@ type ReaderMetadata struct { LibraryID string `json:"library_id"` TotalCharacters int64 `json:"total_characters"` EstimatedPages int `json:"estimated_pages"` + // TreatAsComic: fixed-layout item in a comics/manga library gets + // comic reader treatment (bookmarks only — no highlights, no search). + TreatAsComic bool `json:"treat_as_comic"` } type ReadingProgress struct { diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 3fc8d7c..acae2b6 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -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;