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)
|
||||
|
||||
**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"
|
||||
```
|
||||
|
||||
#### 5.10.4 Font Loading in Templates
|
||||
|
||||
**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)
|
||||
#### 5.10.4 Alternative: Use Google Fonts CDN (Not Recommended)
|
||||
|
||||
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
|
||||
|
||||
**File:** `web/src/reader/ebook/copy-handler.ts`
|
||||
|
||||
Reference in New Issue
Block a user