Reader: Annotationen — Marker, freie Notizen, Margin-Sidebar, Export (SW v22)
Textauswahl→Highlight-Popover war implementiert, aber nie verdrahtet (kein mouseup/touchend-Listener) — als Voraussetzung mit behoben. Neu: freiformige Notizen ohne Textmarkierung, verankert per Klick in einer neuen linken Margin-Sidebar (Notiz am ersten Wort der Zeile auf Klickhöhe); Sidebar zeigt Marker für alle Highlights/Notizen im sichtbaren Bereich und schiebt den Lesetext nach rechts. Export sammelt alle Markierungen/Notizen eines Buchs in Lesereihenfolge, nach Kapitel gruppiert, als Textdatei. EPUB-only — PDFs haben noch keinen Text-Layer (siehe Issue #10). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
17e205b6f0
commit
090aec9c60
4 changed files with 307 additions and 9 deletions
|
|
@ -1800,6 +1800,44 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
|
|||
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
|
||||
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
|
||||
|
||||
/* Freeform note anchor (no colored highlight, just a subtle in-text cue) */
|
||||
.epub-note-anchor { cursor:pointer; border-bottom:2px dotted var(--accent,#e63946); }
|
||||
|
||||
/* Margin ("page margin") panel — left of the reader content, holds highlight/note
|
||||
markers aligned to the text height they belong to. Click empty space to place a
|
||||
new freeform note anchored to that line. */
|
||||
.reader-body-row { display:flex; flex:1; min-height:0; }
|
||||
.reader-margin {
|
||||
width:0; flex-shrink:0; overflow:hidden;
|
||||
display:flex; flex-direction:column;
|
||||
border-right:1px solid var(--border); background:var(--bg);
|
||||
transition:width 0.25s ease;
|
||||
}
|
||||
.reader-margin.open { width:72px; }
|
||||
.reader-margin-header {
|
||||
display:none; align-items:center; justify-content:space-between;
|
||||
padding:6px 8px; border-bottom:1px solid var(--border); white-space:nowrap; flex-shrink:0;
|
||||
}
|
||||
.reader-margin.open .reader-margin-header { display:flex; }
|
||||
.reader-margin-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted,#888); }
|
||||
.reader-margin-markers { position:relative; flex:1; overflow:hidden; cursor:crosshair; }
|
||||
.margin-marker {
|
||||
position:absolute; left:50%; transform:translate(-50%,-50%);
|
||||
width:18px; height:18px; border-radius:50%; border:none; cursor:pointer;
|
||||
display:flex; align-items:center; justify-content:center; font-size:11px; line-height:1;
|
||||
padding:0;
|
||||
}
|
||||
.margin-marker[data-color="yellow"] { background:rgba(241,196,15,.85); }
|
||||
.margin-marker[data-color="green"] { background:rgba(46,204,113,.85); }
|
||||
.margin-marker[data-color="blue"] { background:rgba(52,152,219,.85); }
|
||||
.margin-marker[data-color="red"] { background:rgba(230,57,70,.85); }
|
||||
.margin-marker.margin-marker-note { background:var(--bg-card,#1a1a1a); border:1px solid var(--accent,#e63946); color:var(--accent,#e63946); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.reader-margin.open { width:40px; }
|
||||
.reader-margin-title { display:none; }
|
||||
}
|
||||
|
||||
/* Search matches */
|
||||
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
|
||||
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
||||
|
|
|
|||
262
static/js/app.js
262
static/js/app.js
|
|
@ -3612,7 +3612,10 @@ let _immBarsVisible = true;
|
|||
|
||||
function _immHandleTap(e) {
|
||||
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
|
||||
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover')) return;
|
||||
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover')) return;
|
||||
// Don't toggle bars while the user has just finished selecting text (e.g. to highlight it)
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
// If a footnote popover is open, dismiss it instead of toggling bars
|
||||
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
|
||||
dismissFootnotePopover();
|
||||
|
|
@ -3877,6 +3880,7 @@ async function openBook(bookId) {
|
|||
_suppressScrollJumpDetect();
|
||||
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
|
||||
}
|
||||
_repositionMarginMarkers();
|
||||
});
|
||||
_resizeObserver.observe(contentEl);
|
||||
}
|
||||
|
|
@ -3890,12 +3894,14 @@ async function openBook(bookId) {
|
|||
_scrollJumpRaf = requestAnimationFrame(() => {
|
||||
_scrollJumpRaf = null;
|
||||
_checkScrollJump(contentEl);
|
||||
_repositionMarginMarkers();
|
||||
});
|
||||
}, {passive: true});
|
||||
|
||||
enterReaderImmersiveMode();
|
||||
|
||||
} catch (e) {
|
||||
console.error('openBook failed:', e);
|
||||
overlay.style.display = 'none';
|
||||
markBookBroken(bookId);
|
||||
}
|
||||
|
|
@ -4014,6 +4020,13 @@ function closeReader() {
|
|||
}
|
||||
_currentPositionAnchor = '';
|
||||
|
||||
// Close the margin panel
|
||||
readerAnnotationsMarginOpen = false;
|
||||
const marginEl = $('reader-margin');
|
||||
if (marginEl) marginEl.classList.remove('open');
|
||||
const marginMarkersEl = $('reader-margin-markers');
|
||||
if (marginMarkersEl) marginMarkersEl.innerHTML = '';
|
||||
|
||||
// Clear search before wiping content
|
||||
clearReaderSearch();
|
||||
|
||||
|
|
@ -4180,6 +4193,11 @@ function applyReaderSettings(isPdf) {
|
|||
// PDF invert
|
||||
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
|
||||
else overlay.classList.remove('pdf-inverted');
|
||||
|
||||
// Margin panel (highlights/notes) is EPUB-only for now — PDFs have no text layer yet
|
||||
const marginBtn = $('reader-margin-btn');
|
||||
if (marginBtn) marginBtn.style.display = isPdf ? 'none' : '';
|
||||
if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin();
|
||||
}
|
||||
|
||||
function toggleSettingsPanel() {
|
||||
|
|
@ -4224,7 +4242,7 @@ function toggleSettingsPanel() {
|
|||
`;
|
||||
}
|
||||
|
||||
overlay.insertBefore(panel, contentEl);
|
||||
overlay.insertBefore(panel, $('reader-body-row'));
|
||||
|
||||
if (!isPdf) {
|
||||
const fontRange = panel.querySelector('#rs-font');
|
||||
|
|
@ -4777,7 +4795,7 @@ function toggleReaderSearch() {
|
|||
<span id="rs-search-count" class="muted"></span>
|
||||
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
|
||||
`;
|
||||
overlay.insertBefore(bar, contentEl);
|
||||
overlay.insertBefore(bar, $('reader-body-row'));
|
||||
|
||||
const input = bar.querySelector('#reader-search-input');
|
||||
input.focus();
|
||||
|
|
@ -4982,11 +5000,15 @@ function applyHighlightsToContent() {
|
|||
for (const h of currentHighlights) {
|
||||
try { renderHighlight(h); } catch (e) {}
|
||||
}
|
||||
_repositionMarginMarkers();
|
||||
}
|
||||
|
||||
function renderHighlight(h) {
|
||||
// Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by
|
||||
// rendering, margin-marker positioning and export ordering — all three need the
|
||||
// same "where in the current DOM is this annotation" answer.
|
||||
function resolveAnchorRange(h) {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl || !h.anchor) return;
|
||||
if (!contentEl || !h.anchor) return null;
|
||||
|
||||
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|
||||
|| contentEl;
|
||||
|
|
@ -5017,13 +5039,22 @@ function renderHighlight(h) {
|
|||
}
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
function renderHighlight(h) {
|
||||
const range = resolveAnchorRange(h);
|
||||
if (!range) return;
|
||||
|
||||
try {
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'epub-highlight';
|
||||
mark.dataset.highlightId = h.id;
|
||||
if (h.type === 'note') {
|
||||
mark.className = 'epub-note-anchor';
|
||||
} else {
|
||||
mark.className = 'epub-highlight';
|
||||
mark.dataset.color = h.color || 'yellow';
|
||||
}
|
||||
range.surroundContents(mark);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
|
@ -5168,6 +5199,7 @@ function createHighlight(color, range) {
|
|||
const anchor = buildEpubAnchor(range);
|
||||
const h = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'highlight',
|
||||
anchor,
|
||||
color,
|
||||
note: '',
|
||||
|
|
@ -5178,6 +5210,7 @@ function createHighlight(color, range) {
|
|||
window.getSelection()?.removeAllRanges();
|
||||
dismissHighlightPopover();
|
||||
renderHighlight(h);
|
||||
_repositionMarginMarkers();
|
||||
debounceSaveHighlights();
|
||||
}
|
||||
|
||||
|
|
@ -5185,6 +5218,7 @@ function createHighlightWithNote(range) {
|
|||
const anchor = buildEpubAnchor(range);
|
||||
const h = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'highlight',
|
||||
anchor,
|
||||
color: 'yellow',
|
||||
note: '',
|
||||
|
|
@ -5195,9 +5229,12 @@ function createHighlightWithNote(range) {
|
|||
window.getSelection()?.removeAllRanges();
|
||||
dismissHighlightPopover();
|
||||
renderHighlight(h);
|
||||
_repositionMarginMarkers();
|
||||
openNoteEditor(h);
|
||||
}
|
||||
|
||||
// Freeform note, anchored via the margin panel rather than a text selection —
|
||||
// see createFreeformNote() below. Reused for editing notes on existing highlights too.
|
||||
function openNoteEditor(h) {
|
||||
openSidebar('Edit note', `
|
||||
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea>
|
||||
|
|
@ -5230,6 +5267,7 @@ function deleteHighlight(id) {
|
|||
parent.removeChild(mark);
|
||||
}
|
||||
}
|
||||
_repositionMarginMarkers();
|
||||
debounceSaveHighlights();
|
||||
}
|
||||
|
||||
|
|
@ -5240,6 +5278,204 @@ function dismissHighlightPopover() {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Margin panel — a "page margin" strip left of the reader content. Shows small
|
||||
// markers next to the on-screen highlights/notes, and lets you click empty
|
||||
// space to anchor a freeform note at that line's height (EPUB only).
|
||||
// ---------------------------------------------------------------------------
|
||||
let readerAnnotationsMarginOpen = false;
|
||||
|
||||
function toggleAnnotationsMargin() {
|
||||
const el = $('reader-margin');
|
||||
const markersEl = $('reader-margin-markers');
|
||||
if (!el || !markersEl || currentPdfDoc) return;
|
||||
readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen;
|
||||
el.classList.toggle('open', readerAnnotationsMarginOpen);
|
||||
if (readerAnnotationsMarginOpen) {
|
||||
// Reposition once now (pre-transition) and once more after the width
|
||||
// transition settles, since content reflow can shift line heights.
|
||||
_repositionMarginMarkers();
|
||||
el.addEventListener('transitionend', _repositionMarginMarkers, {once: true});
|
||||
} else {
|
||||
markersEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _repositionMarginMarkers() {
|
||||
const markersEl = $('reader-margin-markers');
|
||||
if (!markersEl) return;
|
||||
markersEl.innerHTML = '';
|
||||
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
||||
|
||||
const areaRect = markersEl.getBoundingClientRect();
|
||||
for (const h of currentHighlights) {
|
||||
let range;
|
||||
try { range = resolveAnchorRange(h); } catch (e) { continue; }
|
||||
if (!range) continue;
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) continue; // detached/invalid
|
||||
const y = rect.top + rect.height / 2;
|
||||
if (y < areaRect.top - 20 || y > areaRect.bottom + 20) continue; // off-screen band
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'margin-marker' + (h.type === 'note' ? ' margin-marker-note' : '');
|
||||
if (h.type === 'note') {
|
||||
btn.textContent = '✎';
|
||||
} else {
|
||||
btn.dataset.color = h.color || 'yellow';
|
||||
}
|
||||
btn.style.top = (y - areaRect.top) + 'px';
|
||||
btn.dataset.highlightId = h.id;
|
||||
const preview = h.note || h.anchor?.quote || '';
|
||||
if (preview) btn.title = preview.slice(0, 80);
|
||||
markersEl.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMarginClick(e) {
|
||||
const markerBtn = e.target.closest('.margin-marker');
|
||||
if (markerBtn) {
|
||||
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
|
||||
if (h) showHighlightTooltip(markerBtn, h);
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty space in the markers area → place a new freeform note anchored to
|
||||
// the first word of whatever line sits at this click height.
|
||||
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
||||
const markersEl = $('reader-margin-markers');
|
||||
if (!markersEl || !markersEl.contains(e.target)) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const x = contentEl.getBoundingClientRect().left + 4;
|
||||
const range = _wordRangeAtPoint(x, e.clientY);
|
||||
if (range) createFreeformNote(range);
|
||||
}
|
||||
|
||||
// Finds the word starting at (or just after) the given viewport point, by
|
||||
// probing near the left edge of the reader content at that height — since
|
||||
// text is left-aligned, that lands at (or very near) the start of the line.
|
||||
function _wordRangeAtPoint(x, y) {
|
||||
let caret = null;
|
||||
if (document.caretRangeFromPoint) {
|
||||
caret = document.caretRangeFromPoint(x, y);
|
||||
} else if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(x, y);
|
||||
if (pos) {
|
||||
caret = document.createRange();
|
||||
caret.setStart(pos.offsetNode, pos.offset);
|
||||
}
|
||||
}
|
||||
if (!caret) return null;
|
||||
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl || !contentEl.contains(caret.startContainer)) return null;
|
||||
|
||||
let node = caret.startContainer;
|
||||
let offset = caret.startOffset;
|
||||
if (node.nodeType !== Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
|
||||
walker.currentNode = node;
|
||||
node = walker.nextNode();
|
||||
if (!node) return null;
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const text = node.textContent;
|
||||
let start = offset;
|
||||
while (start < text.length && /\s/.test(text[start])) start++;
|
||||
if (start >= text.length) return null;
|
||||
let wordStart = start;
|
||||
while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
|
||||
let wordEnd = start;
|
||||
while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
|
||||
if (wordEnd <= wordStart) return null;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(node, wordStart);
|
||||
range.setEnd(node, wordEnd);
|
||||
return range;
|
||||
}
|
||||
|
||||
function createFreeformNote(range) {
|
||||
const anchor = buildEpubAnchor(range);
|
||||
const h = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'note',
|
||||
anchor,
|
||||
color: null,
|
||||
note: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
currentHighlights.push(h);
|
||||
highlightsDirty = true;
|
||||
renderHighlight(h);
|
||||
_repositionMarginMarkers();
|
||||
debounceSaveHighlights();
|
||||
openNoteEditor(h);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export annotations — walks all highlights/notes of the current book in
|
||||
// reading order and offers them as a downloadable plain-text file.
|
||||
// ---------------------------------------------------------------------------
|
||||
function _tocTitleForChapterSrc(chapterSrc) {
|
||||
if (!chapterSrc) return '';
|
||||
const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc);
|
||||
return entry ? entry.label : '';
|
||||
}
|
||||
|
||||
function exportAnnotations() {
|
||||
if (!currentHighlights.length) {
|
||||
customAlert('No highlights or notes in this book yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = [];
|
||||
for (const h of currentHighlights) {
|
||||
let range;
|
||||
try { range = resolveAnchorRange(h); } catch (e) { range = null; }
|
||||
resolved.push({h, range});
|
||||
}
|
||||
resolved.sort((a, b) => {
|
||||
if (!a.range || !b.range) return 0;
|
||||
const pos = a.range.compareBoundaryPoints(Range.START_TO_START, b.range);
|
||||
return pos;
|
||||
});
|
||||
|
||||
const titleEl = $('reader-title');
|
||||
const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0];
|
||||
const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||
|
||||
let lastChapter = null;
|
||||
for (const {h} of resolved) {
|
||||
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
|
||||
if (chapterTitle !== lastChapter) {
|
||||
lines.push(`── ${chapterTitle || 'Untitled section'} ──`);
|
||||
lastChapter = chapterTitle;
|
||||
}
|
||||
if (h.type === 'note') {
|
||||
lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`);
|
||||
if (h.note) lines.push(` ${h.note}`);
|
||||
} else {
|
||||
lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`);
|
||||
if (h.note) lines.push(` Note: ${h.note}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-annotations.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footnote popover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -5467,5 +5703,19 @@ function openRadioSidebar() {
|
|||
showFootnotePopover(link);
|
||||
}
|
||||
});
|
||||
|
||||
// Text selection → highlight color/note popover
|
||||
_readerContentEl.addEventListener('mouseup', handleReaderSelection);
|
||||
_readerContentEl.addEventListener('touchend', e => {
|
||||
const target = e.target;
|
||||
setTimeout(() => handleReaderSelection({target}), 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Margin ("page margin") panel — click a marker to view/edit, click empty
|
||||
// space to anchor a new freeform note at that line's height
|
||||
const _readerMarginEl = $('reader-margin');
|
||||
if (_readerMarginEl) {
|
||||
_readerMarginEl.addEventListener('click', handleMarginClick);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v21';
|
||||
const CACHE = 'diora-v22';
|
||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
|
|
@ -343,12 +343,22 @@
|
|||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
|
||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes & highlights">✎</button>
|
||||
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)">⚑</button>
|
||||
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reader-body-row" class="reader-body-row">
|
||||
<aside id="reader-margin" class="reader-margin">
|
||||
<div class="reader-margin-header">
|
||||
<span class="reader-margin-title">Notes</span>
|
||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
||||
</div>
|
||||
<div id="reader-margin-markers" class="reader-margin-markers"></div>
|
||||
</aside>
|
||||
<div id="reader-content" class="reader-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SIDEBAR ===== -->
|
||||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue