Reader: Annotationen — Marker, freie Notizen, Margin-Sidebar, Export (SW v22)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s

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:
marwin 2026-08-04 10:17:01 +02:00
parent 17e205b6f0
commit 090aec9c60
4 changed files with 307 additions and 9 deletions

View file

@ -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="blue"] { background:rgba(52,152,219,.35); }
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.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 */ /* Search matches */
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; } 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); } mark.reader-search-match.active { background:rgba(230,57,70,.7); }

View file

@ -3612,7 +3612,10 @@ let _immBarsVisible = true;
function _immHandleTap(e) { function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) // 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 a footnote popover is open, dismiss it instead of toggling bars
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) { if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
dismissFootnotePopover(); dismissFootnotePopover();
@ -3877,6 +3880,7 @@ async function openBook(bookId) {
_suppressScrollJumpDetect(); _suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
} }
_repositionMarginMarkers();
}); });
_resizeObserver.observe(contentEl); _resizeObserver.observe(contentEl);
} }
@ -3890,12 +3894,14 @@ async function openBook(bookId) {
_scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = requestAnimationFrame(() => {
_scrollJumpRaf = null; _scrollJumpRaf = null;
_checkScrollJump(contentEl); _checkScrollJump(contentEl);
_repositionMarginMarkers();
}); });
}, {passive: true}); }, {passive: true});
enterReaderImmersiveMode(); enterReaderImmersiveMode();
} catch (e) { } catch (e) {
console.error('openBook failed:', e);
overlay.style.display = 'none'; overlay.style.display = 'none';
markBookBroken(bookId); markBookBroken(bookId);
} }
@ -4014,6 +4020,13 @@ function closeReader() {
} }
_currentPositionAnchor = ''; _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 // Clear search before wiping content
clearReaderSearch(); clearReaderSearch();
@ -4180,6 +4193,11 @@ function applyReaderSettings(isPdf) {
// PDF invert // PDF invert
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
else overlay.classList.remove('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() { function toggleSettingsPanel() {
@ -4224,7 +4242,7 @@ function toggleSettingsPanel() {
`; `;
} }
overlay.insertBefore(panel, contentEl); overlay.insertBefore(panel, $('reader-body-row'));
if (!isPdf) { if (!isPdf) {
const fontRange = panel.querySelector('#rs-font'); const fontRange = panel.querySelector('#rs-font');
@ -4777,7 +4795,7 @@ function toggleReaderSearch() {
<span id="rs-search-count" class="muted"></span> <span id="rs-search-count" class="muted"></span>
<button class="btn-icon" id="rs-search-clear" title="Close"></button> <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'); const input = bar.querySelector('#reader-search-input');
input.focus(); input.focus();
@ -4982,11 +5000,15 @@ function applyHighlightsToContent() {
for (const h of currentHighlights) { for (const h of currentHighlights) {
try { renderHighlight(h); } catch (e) {} 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'); 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 || '')}"]`) const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl; || contentEl;
@ -5017,13 +5039,22 @@ function renderHighlight(h) {
} }
} }
return range;
}
function renderHighlight(h) {
const range = resolveAnchorRange(h);
if (!range) return; if (!range) return;
try { try {
const mark = document.createElement('mark'); const mark = document.createElement('mark');
mark.className = 'epub-highlight';
mark.dataset.highlightId = h.id; mark.dataset.highlightId = h.id;
mark.dataset.color = h.color || 'yellow'; if (h.type === 'note') {
mark.className = 'epub-note-anchor';
} else {
mark.className = 'epub-highlight';
mark.dataset.color = h.color || 'yellow';
}
range.surroundContents(mark); range.surroundContents(mark);
} catch (e) {} } catch (e) {}
} }
@ -5168,6 +5199,7 @@ function createHighlight(color, range) {
const anchor = buildEpubAnchor(range); const anchor = buildEpubAnchor(range);
const h = { const h = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: 'highlight',
anchor, anchor,
color, color,
note: '', note: '',
@ -5178,6 +5210,7 @@ function createHighlight(color, range) {
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
dismissHighlightPopover(); dismissHighlightPopover();
renderHighlight(h); renderHighlight(h);
_repositionMarginMarkers();
debounceSaveHighlights(); debounceSaveHighlights();
} }
@ -5185,6 +5218,7 @@ function createHighlightWithNote(range) {
const anchor = buildEpubAnchor(range); const anchor = buildEpubAnchor(range);
const h = { const h = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: 'highlight',
anchor, anchor,
color: 'yellow', color: 'yellow',
note: '', note: '',
@ -5195,9 +5229,12 @@ function createHighlightWithNote(range) {
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
dismissHighlightPopover(); dismissHighlightPopover();
renderHighlight(h); renderHighlight(h);
_repositionMarginMarkers();
openNoteEditor(h); 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) { function openNoteEditor(h) {
openSidebar('Edit note', ` openSidebar('Edit note', `
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea> <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); parent.removeChild(mark);
} }
} }
_repositionMarginMarkers();
debounceSaveHighlights(); 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 // Footnote popover
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -5467,5 +5703,19 @@ function openRadioSidebar() {
showFootnotePopover(link); 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);
} }
})(); })();

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * 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 PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -343,11 +343,21 @@
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button> <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-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-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes &amp; 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" 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> <button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div> </div>
</div> </div>
<div id="reader-content" class="reader-content"></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> </div>
<!-- ===== SIDEBAR ===== --> <!-- ===== SIDEBAR ===== -->