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
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
// Handle text copying with citation
|
||||||
|
|
||||||
|
// Handle text copying with citation
|
||||||
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
|
async function copySelection(mediaItem: MediaItemSummary): Promise<boolean> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = '<p class="text-sm">Loading...</p>';
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
|
||||||
|
// Look up word
|
||||||
|
lookupWord(word)
|
||||||
|
.then((entry) => {
|
||||||
|
popup.innerHTML = `
|
||||||
|
<h3 class="font-bold text-lg">${entry.word}</h3>
|
||||||
|
<p class="text-sm italic">${entry.part_of_speech || ""}</p>
|
||||||
|
<p class="mt-2">${entry.definition}</p>
|
||||||
|
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ""}
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<button class="prev-page" ${currentPage === 1 ? "disabled" : ""}>← Previous</button>
|
||||||
|
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
|
||||||
|
<button class="next-page" ${currentPage === pageCount ? "disabled" : ""}>Next →</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user