Remove duplicate and incomplete sections from reader implementation plan
Removed 3 duplicate/incomplete sections: - Section 5.8 (incomplete typography-engine.ts without font support) - Section 5.12 (duplicate search.ts - kept version in section 5.9) - Section 5.10.4 (incomplete reader.templ template - kept complete version in section 12.1) The plan now contains only complete, implementation-ready sections: - Complete typography-engine.ts with getFontStack() and all 8 bundled fonts - Complete search.ts matching actual implementation with export functions - Complete reader.templ SSR template with Alpine.js integration This resolves confusion about which version to follow and ensures the plan matches the actual codebase implementation.
This commit is contained in:
@@ -4606,95 +4606,6 @@ function getElementChildren(element: Element): Element[] {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.8 Typography Engine (Procedural)
|
|
||||||
|
|
||||||
**File:** `web/src/reader/ebook/typography-engine.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Typography engine for ebook rendering
|
|
||||||
// Procedural style: Functions, not classes
|
|
||||||
|
|
||||||
interface TypographyConfig {
|
|
||||||
fontSize: number;
|
|
||||||
lineHeight: number;
|
|
||||||
textAlign: 'left' | 'justify';
|
|
||||||
hyphenate: boolean;
|
|
||||||
ligatures: boolean;
|
|
||||||
fontSmoothing: 'auto' | 'grayscale';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTypographyConfig(
|
|
||||||
element: HTMLElement,
|
|
||||||
config: TypographyConfig
|
|
||||||
): void {
|
|
||||||
// Enable/disable ligatures
|
|
||||||
setLigatures(element, config.ligatures);
|
|
||||||
|
|
||||||
// Enable/disable hyphenation
|
|
||||||
if (config.hyphenate) {
|
|
||||||
enableHyphenation(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply justification settings
|
|
||||||
if (config.textAlign === 'justify') {
|
|
||||||
enableJustification(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply font smoothing
|
|
||||||
element.style.fontSmooth = config.fontSmoothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLigatures(element: HTMLElement, enabled: boolean): void {
|
|
||||||
if (enabled) {
|
|
||||||
element.style.fontVariantLigatures = 'common-ligatures';
|
|
||||||
element.style.fontFeatureSettings = '"liga", "dlig"';
|
|
||||||
} else {
|
|
||||||
element.style.fontVariantLigatures = 'no-common-ligatures';
|
|
||||||
element.style.fontFeatureSettings = 'normal';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableHyphenation(element: HTMLElement): void {
|
|
||||||
element.style.hyphens = 'auto';
|
|
||||||
element.style.hyphenateLimitChars = '6 3 3';
|
|
||||||
|
|
||||||
// Add language attribute from EPUB metadata
|
|
||||||
const lang = element.closest('[data-language]')?.getAttribute('data-language') || 'en';
|
|
||||||
element.setAttribute('lang', lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableJustification(element: HTMLElement): void {
|
|
||||||
element.style.wordBreak = 'normal';
|
|
||||||
element.style.overflowWrap = 'break-word';
|
|
||||||
element.style.wordWrap = 'break-word';
|
|
||||||
element.style.letterSpacing = '0.01em';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function measureReadingTime(
|
|
||||||
container: HTMLElement,
|
|
||||||
wordsPerMinute: number = 250
|
|
||||||
): number {
|
|
||||||
const content = container.querySelector('.ebook-content');
|
|
||||||
if (!content) return 0;
|
|
||||||
|
|
||||||
const text = content.textContent || '';
|
|
||||||
const words = text.split(/\s+/).length;
|
|
||||||
const minutes = words / wordsPerMinute;
|
|
||||||
|
|
||||||
return Math.ceil(minutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWordCount(container: HTMLElement): number {
|
|
||||||
const content = container.querySelector('.ebook-content');
|
|
||||||
if (!content) return 0;
|
|
||||||
|
|
||||||
const text = content.textContent || '';
|
|
||||||
return text.split(/\s+/).length;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.9 Ebook Search (Procedural)
|
### 5.9 Ebook Search (Procedural)
|
||||||
|
|
||||||
**File:** `web/src/reader/ebook/search.ts`
|
**File:** `web/src/reader/ebook/search.ts`
|
||||||
@@ -5103,194 +5014,7 @@ All fonts use SIL Open Font License 1.1
|
|||||||
WOFF2 format, ~1.2MB total"
|
WOFF2 format, ~1.2MB total"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5.10.4 Font Loading in Templates
|
#### 5.10.4 Alternative: Use Google Fonts CDN (Not Recommended)
|
||||||
|
|
||||||
**File:** `templates/reader.templ` (updated)
|
|
||||||
|
|
||||||
Add to `<head>` section:
|
|
||||||
|
|
||||||
```go
|
|
||||||
templ Reader(user User, metadata ReaderMetadata) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>{ metadata.title } - Bookhoard Reader</title>
|
|
||||||
<link rel="manifest" href="/static/manifest.json"/>
|
|
||||||
<link href="/static/reader-fonts.css" rel="stylesheet"/>
|
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
|
||||||
</head>
|
|
||||||
<body class="bg-white dark:bg-gray-900" data-media-item-id={ metadata.id }>
|
|
||||||
<!-- Reader Chrome (Navigation Bars) -->
|
|
||||||
<div id="reader-chrome" class="reader-chrome bg-gray-100 dark:bg-gray-800 border-b border-gray-300 dark:border-gray-700">
|
|
||||||
<!-- Top Navigation Bar -->
|
|
||||||
<header class="flex items-center justify-between px-4 py-2">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<button
|
|
||||||
hx-get="/library"
|
|
||||||
hx-push-url="true"
|
|
||||||
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
← Back to Library
|
|
||||||
</button>
|
|
||||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
|
|
||||||
{ metadata.title }
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<!-- Progress Indicator -->
|
|
||||||
<div id="progress-indicator" class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span id="current-position">--</span> / <span id="total-position">--</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Button -->
|
|
||||||
<button
|
|
||||||
x-data="{ open: false }"
|
|
||||||
@click="open = !open"
|
|
||||||
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
⚙️ Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Table of Contents Panel (Slide-in) -->
|
|
||||||
<div id="toc-panel" class="hidden fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-900 shadow-lg z-50">
|
|
||||||
<div class="p-4">
|
|
||||||
<h2 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Table of Contents</h2>
|
|
||||||
<nav id="toc-list" class="space-y-2">
|
|
||||||
<!-- TOC items will be populated by JavaScript -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reader Content Area -->
|
|
||||||
<main id="reader-content" class="flex-1 overflow-auto">
|
|
||||||
<!-- Content will be rendered here by the appropriate reader -->
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Bottom Navigation Bar -->
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-100 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 p-4">
|
|
||||||
<div class="flex items-center justify-between max-w-4xl mx-auto">
|
|
||||||
<button
|
|
||||||
id="prev-button"
|
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
← Previous
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex-1 mx-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="position-slider"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value="0"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="next-button"
|
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Settings Panel (Slide-in) -->
|
|
||||||
<div id="settings-panel" class="hidden fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-900 shadow-lg z-50 overflow-y-auto">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Reader Settings</h2>
|
|
||||||
<button id="close-settings" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Font Settings -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font</h3>
|
|
||||||
<select id="font-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
|
|
||||||
<option value="literata">Literata (Default)</option>
|
|
||||||
<option value="crimson">Crimson Text</option>
|
|
||||||
<option value="source-serif">Source Serif 4</option>
|
|
||||||
<option value="eb-garamond">EB Garamond</option>
|
|
||||||
<option value="libertinus">Libertinus Serif</option>
|
|
||||||
<option value="noto-serif">Noto Serif</option>
|
|
||||||
<option value="charis-sil">Charis SIL</option>
|
|
||||||
<option value="ibm-plex">IBM Plex Serif</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Font Size -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font Size: <span id="font-size-value">16px</span></h3>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="font-size-slider"
|
|
||||||
min="12"
|
|
||||||
max="24"
|
|
||||||
value="16"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theme -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reading Theme</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="light">
|
|
||||||
☀️ Light
|
|
||||||
</button>
|
|
||||||
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="sepia">
|
|
||||||
📜 Sepia
|
|
||||||
</button>
|
|
||||||
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="dark">
|
|
||||||
🌙 Dark
|
|
||||||
</button>
|
|
||||||
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="night">
|
|
||||||
⭐ Night
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Line Height -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Line Spacing</h3>
|
|
||||||
<select id="line-height-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
|
|
||||||
<option value="1.4">Compact</option>
|
|
||||||
<option value="1.6" selected>Normal</option>
|
|
||||||
<option value="1.8">Relaxed</option>
|
|
||||||
<option value="2.0">Loose</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Margins -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Page Margins</h3>
|
|
||||||
<select id="margin-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
|
|
||||||
<option value="narrow">Narrow</option>
|
|
||||||
<option value="medium" selected>Medium</option>
|
|
||||||
<option value="wide">Wide</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Bundle -->
|
|
||||||
<script src="/static/reader.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended)
|
|
||||||
|
|
||||||
If you don't want to bundle fonts (slower initial load, privacy concerns):
|
If you don't want to bundle fonts (slower initial load, privacy concerns):
|
||||||
|
|
||||||
@@ -5820,166 +5544,6 @@ function getWordCount(container: HTMLElement): number {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.12 Search Within Ebook
|
|
||||||
|
|
||||||
**File:** `web/src/reader/ebook/search.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Search within ebook content
|
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
cfi: string;
|
|
||||||
snippet: string;
|
|
||||||
chapterTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search within ebook content
|
|
||||||
// Procedural implementation (no OOP)
|
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
cfi: string;
|
|
||||||
snippet: string;
|
|
||||||
chapterTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchEbook(
|
|
||||||
epubPackage: EPUBPackage,
|
|
||||||
query: string
|
|
||||||
): Promise<SearchResult[]> {
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
for (const [index, spineItem] of epubPackage.spine.entries()) {
|
|
||||||
const doc = await getSpineItemDocument(epubPackage, spineItem);
|
|
||||||
|
|
||||||
if (!doc) continue;
|
|
||||||
|
|
||||||
const chapterTitle = getChapterTitle(epubPackage, spineItem);
|
|
||||||
const textNodes = findTextNodes(doc.body);
|
|
||||||
|
|
||||||
for (const node of textNodes) {
|
|
||||||
const text = node.textContent || '';
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
|
|
||||||
let foundAt = 0;
|
|
||||||
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
|
||||||
const cfi = generateSearchCFI(node, foundAt);
|
|
||||||
const snippet = extractSearchSnippet(text, foundAt, query.length);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
cfi,
|
|
||||||
snippet,
|
|
||||||
chapterTitle
|
|
||||||
});
|
|
||||||
|
|
||||||
foundAt += lowerQuery.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSpineItemDocument(
|
|
||||||
epubPackage: EPUBPackage,
|
|
||||||
spineItem: EPUBSpineItem
|
|
||||||
): Promise<Document | null> {
|
|
||||||
try {
|
|
||||||
const content = await epubPackage.resources.get(spineItem.href)?.text();
|
|
||||||
if (!content) return null;
|
|
||||||
|
|
||||||
const parser = new DOMParser();
|
|
||||||
return parser.parseFromString(content, 'text/html');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load spine item:', spineItem.href, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findTextNodes(root: Node): Text[] {
|
|
||||||
const textNodes: Text[] = [];
|
|
||||||
|
|
||||||
const walker = document.createTreeWalker(
|
|
||||||
root,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
{
|
|
||||||
acceptNode: (node) => {
|
|
||||||
const parent = node.parentElement;
|
|
||||||
if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.textContent?.trim()) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let node: Node | null;
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
textNodes.push(node as Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textNodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSearchCFI(node: Text, offset: number): string {
|
|
||||||
const path: number[] = [];
|
|
||||||
let current: Node | null = node;
|
|
||||||
|
|
||||||
while (current && current.parentNode) {
|
|
||||||
const parent = current.parentNode;
|
|
||||||
const siblings = Array.from(parent.childNodes)
|
|
||||||
.filter(n => n.nodeType === Node.ELEMENT_NODE);
|
|
||||||
const index = siblings.indexOf(current as Node);
|
|
||||||
|
|
||||||
path.unshift(index);
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spineIndex = 0;
|
|
||||||
|
|
||||||
return generateCFI(spineIndex, path, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSearchSnippet(text: string, offset: number, length: number): string {
|
|
||||||
const contextBefore = 30;
|
|
||||||
const contextAfter = 50;
|
|
||||||
|
|
||||||
const start = Math.max(0, offset - contextBefore);
|
|
||||||
const end = Math.min(text.length, offset + length + contextAfter);
|
|
||||||
|
|
||||||
let snippet = text.substring(start, end);
|
|
||||||
|
|
||||||
if (start > 0) snippet = '...' + snippet;
|
|
||||||
if (end < text.length) snippet = snippet + '...';
|
|
||||||
|
|
||||||
return snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChapterTitle(
|
|
||||||
epubPackage: EPUBPackage,
|
|
||||||
spineItem: EPUBSpineItem
|
|
||||||
): string {
|
|
||||||
for (const toc of epubPackage.toc) {
|
|
||||||
if (toc.href === spineItem.href) {
|
|
||||||
return toc.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of toc.children) {
|
|
||||||
if (child.href === spineItem.href) {
|
|
||||||
return child.label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Chapter ' + (epubPackage.spine.indexOf(spineItem) + 1);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.13 Copy Text Handler
|
### 5.13 Copy Text Handler
|
||||||
|
|
||||||
**File:** `web/src/reader/ebook/copy-handler.ts`
|
**File:** `web/src/reader/ebook/copy-handler.ts`
|
||||||
|
|||||||
Reference in New Issue
Block a user