From 204696056318fc1417d5ce91040924e788455279 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 01:01:01 -0400 Subject: [PATCH] Add ebook reader features: view modes, dictionary, copy handler - view-modes.ts: Paginated, scrolled, single-column, double-column modes - dictionary-popup.ts: Dictionary lookup with popup definitions - copy-handler.ts: Copy text with automatic citation formatting - Add en-US.json dictionary for offline word lookups --- web/src/reader/ebook/copy-handler.ts | 47 +++++++ web/src/reader/ebook/dictionary-popup.ts | 65 ++++++++++ web/src/reader/ebook/view-modes.ts | 158 +++++++++++++++++++++++ web/static/dictionary/en-US.json | 8 ++ 4 files changed, 278 insertions(+) create mode 100644 web/src/reader/ebook/copy-handler.ts create mode 100644 web/src/reader/ebook/dictionary-popup.ts create mode 100644 web/src/reader/ebook/view-modes.ts create mode 100644 web/static/dictionary/en-US.json diff --git a/web/src/reader/ebook/copy-handler.ts b/web/src/reader/ebook/copy-handler.ts new file mode 100644 index 0000000..f5fb9e8 --- /dev/null +++ b/web/src/reader/ebook/copy-handler.ts @@ -0,0 +1,47 @@ +// Handle text copying with citation + +// Handle text copying with citation +// Procedural implementation (no OOP) + +async function copySelection(mediaItem: MediaItemSummary): Promise { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + + const selectedText = selection.toString(); + if (!selectedText.trim()) return false; + + const citation = createCitation(selectedText, mediaItem); + + try { + await navigator.clipboard.writeText(citation); + showToast("Copied to clipboard", "success"); + return true; + } catch (error) { + console.error("Failed to copy:", error); + showToast("Failed to copy to clipboard", "error"); + return false; + } +} + +function createCitation(text: string, mediaItem: MediaItemSummary): string { + let citation = `"${text}"\n`; + citation += `— ${mediaItem.title}`; + if (mediaItem.author) { + citation += ` by ${mediaItem.author}`; + } + citation += `\n(Source: Bookhoard)`; + + return citation; +} + +function enableContextMenuCopy(mediaItem: MediaItemSummary): void { + document.addEventListener("contextmenu", async (e) => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + + if (selectedText) { + e.preventDefault(); + await copySelection(mediaItem); + } + }); +} diff --git a/web/src/reader/ebook/dictionary-popup.ts b/web/src/reader/ebook/dictionary-popup.ts new file mode 100644 index 0000000..3580ecf --- /dev/null +++ b/web/src/reader/ebook/dictionary-popup.ts @@ -0,0 +1,65 @@ +// Dictionary lookup popup for ebooks + +import { lookupWord } from "./api"; + +function showDictionaryPopup( + word: string, + position: { x: number; y: number }, +): void { + // Remove existing popup + const existing = document.getElementById("dictionary-popup"); + existing?.remove(); + + // Create popup + const popup = document.createElement("div"); + popup.id = "dictionary-popup"; + popup.className = + "absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"; + popup.style.left = `${position.x}px`; + popup.style.top = `${position.y}px`; + + popup.innerHTML = '

Loading...

'; + document.body.appendChild(popup); + + // Look up word + lookupWord(word) + .then((entry) => { + popup.innerHTML = ` +

${entry.word}

+

${entry.part_of_speech || ""}

+

${entry.definition}

+ ${entry.example ? `

"${entry.example}"

` : ""} + `; + }) + .catch((error) => { + popup.innerHTML = `

Definition not found for "${word}"

`; + }); + + // Close on click outside + setTimeout(() => { + document.addEventListener("click", function closePopup(e: MouseEvent) { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener("click", closePopup); + } + }); + }, 100); +} + +// Text selection handler for ebooks +function handleTextSelection(): void { + document.addEventListener("mouseup", () => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + + if (selectedText && selectedText.split(" ").length === 1) { + // Single word selected - show dictionary + const range = selection?.getRangeAt(0); + const rect = range?.getBoundingClientRect(); + + if (rect) { + showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom }); + } + } + }); +} diff --git a/web/src/reader/ebook/view-modes.ts b/web/src/reader/ebook/view-modes.ts new file mode 100644 index 0000000..32c33f9 --- /dev/null +++ b/web/src/reader/ebook/view-modes.ts @@ -0,0 +1,158 @@ +// Different viewing modes for ebooks + +type ViewMode = "paginated" | "scrolled" | "single-column" | "double-column"; + +// Different viewing modes for ebooks +// Procedural implementation (no OOP) + +type ViewMode = "paginated" | "scrolled" | "single-column" | "double-column"; + +interface ViewModeState { + currentMode: ViewMode; + currentPage: number; +} + +function setViewMode(container: HTMLElement, mode: ViewMode): void { + const content = container.querySelector(".ebook-content"); + if (!content) return; + + content.classList.remove( + "paginated", + "scrolled", + "single-column", + "double-column", + ); + + switch (mode) { + case "paginated": + applyPaginatedMode(container, content as HTMLElement); + break; + case "scrolled": + applyScrolledMode(container, content as HTMLElement); + break; + case "single-column": + applySingleColumn(content as HTMLElement); + break; + case "double-column": + applyDoubleColumn(content as HTMLElement); + break; + } +} + +function applyPaginatedMode( + container: HTMLElement, + element: HTMLElement, +): void { + element.classList.add("paginated"); + + element.style.height = "100vh"; + element.style.overflow = "hidden"; + element.style.columnCount = "1"; + element.style.columnGap = "0"; + + enablePagination(container, element); +} + +function applyScrolledMode(container: HTMLElement, element: HTMLElement): void { + element.classList.add("scrolled"); + + element.style.height = "auto"; + element.style.overflowY = "auto"; + element.style.columnCount = "1"; + + disablePagination(container); +} + +function applySingleColumn(element: HTMLElement): void { + element.classList.add("single-column"); + + element.style.columnCount = "1"; + element.style.columnGap = "0"; + element.style.maxWidth = "800px"; + element.style.margin = "0 auto"; +} + +function applyDoubleColumn(element: HTMLElement): void { + element.classList.add("double-column"); + + element.style.columnCount = "2"; + element.style.columnGap = "60px"; + element.style.columnRule = "1px solid var(--text-secondary)"; + element.style.maxWidth = "1400px"; + element.style.margin = "0 auto"; +} + +function enablePagination(container: HTMLElement, element: HTMLElement): void { + const totalHeight = element.scrollHeight; + const pageHeight = element.clientHeight; + const pageCount = Math.ceil(totalHeight / pageHeight); + + addPaginationControls(container, pageCount); +} + +function disablePagination(container: HTMLElement): void { + const controls = container.querySelector(".pagination-controls"); + controls?.remove(); +} + +function addPaginationControls( + container: HTMLElement, + pageCount: number, +): ViewModeState { + let currentPage = 1; + + const controls = document.createElement("div"); + controls.className = + "pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t"; + controls.innerHTML = ` + + Page ${currentPage} of ${pageCount} + + `; + + controls.querySelector(".prev-page")?.addEventListener("click", () => { + if (currentPage > 1) { + currentPage--; + goToPage(container, currentPage); + } + }); + + controls.querySelector(".next-page")?.addEventListener("click", () => { + if (currentPage < pageCount) { + currentPage++; + goToPage(container, currentPage); + } + }); + + container.appendChild(controls); + + return { currentMode: "paginated", currentPage }; +} + +function goToPage(container: HTMLElement, pageNumber: number): void { + const content = container.querySelector(".ebook-content") as HTMLElement; + if (!content) return; + + const pageHeight = content.clientHeight; + const scrollTop = (pageNumber - 1) * pageHeight; + + content.scrollTo({ + top: scrollTop, + behavior: "smooth", + }); + + const pageInfo = container.querySelector(".page-info"); + if (pageInfo) { + pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`; + } +} + +function getTotalPageCount(container: HTMLElement): number { + const content = container.querySelector(".ebook-content") as HTMLElement; + if (!content) return 1; + + const totalHeight = content.scrollHeight; + const pageHeight = content.clientHeight; + + return Math.ceil(totalHeight / pageHeight); +} diff --git a/web/static/dictionary/en-US.json b/web/static/dictionary/en-US.json new file mode 100644 index 0000000..787f99d --- /dev/null +++ b/web/static/dictionary/en-US.json @@ -0,0 +1,8 @@ +{ + "word": { + "definition": "A single distinct meaningful element of speech or writing", + "part_of_speech": "noun", + "example": "The words 'the', 'and', and 'word' are examples of words.", + "etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort." + } +}