Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v23)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 14s

Drei Nachbesserungen am Annotationen-Feature nach Live-Test:

- Neuer Stift-Button (Marker-Modus) in der Kopfleiste: Textauswahl löst das
  Highlight-Popup nur noch aus, wenn der Modus aktiv ist — vorher war die
  Funktion unauffindbar (impliziter "einfach Text markieren"-Gestus ohne
  sichtbaren Einstiegspunkt) und hätte jede Textauswahl (auch zum Kopieren)
  gekapert.
- Freiform-Notizen bekommen keine Textdekoration mehr im Lesefluss — der
  Anker dient nur der Positionierung im Rand, nicht der optischen Markierung.
- Die Margin-Sidebar zeigt Notiztext jetzt direkt lesbar als kleine
  Post-it-Karte an der zugehörigen Stelle (mit Stapel-Logik gegen
  Überlappung), statt nur ein abstraktes Symbol zum Anklicken. Reine
  Highlights ohne Notiz bleiben ein kleiner Farbpunkt.
- Highlight-Popover/Tooltip klammern ihre Position jetzt hart an den
  Viewport, damit eine Auswahl nahe Seitenrand nicht unsichtbar außerhalb
  des sichtbaren Bereichs landet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 10:49:30 +02:00
parent 090aec9c60
commit bc180daab0
4 changed files with 122 additions and 44 deletions

View file

@ -1800,12 +1800,12 @@ 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. */
/* Margin ("page margin") panel left of the reader content. Highlights without
a note show as a small color dot; anything with note text (freeform notes or
a highlight+note) shows as a readable little "post-it" card, positioned next
to the text height it belongs to. Click empty space to place a new freeform
note anchored to that line. Freeform notes have NO in-text mark the anchor
is purely positional, only ever visible here in the margin. */
.reader-body-row { display:flex; flex:1; min-height:0; }
.reader-margin {
width:0; flex-shrink:0; overflow:hidden;
@ -1813,28 +1813,40 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
border-right:1px solid var(--border); background:var(--bg);
transition:width 0.25s ease;
}
.reader-margin.open { width:72px; }
.reader-margin.open { width:220px; }
.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;
.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; }
.margin-note { position:absolute; left:8px; right:8px; cursor:pointer; }
.margin-note-dot { left:8px; right:auto; width:12px; height:12px; border-radius:50%; }
.margin-note-dot[data-color="yellow"] { background:rgba(241,196,15,.85); }
.margin-note-dot[data-color="green"] { background:rgba(46,204,113,.85); }
.margin-note-dot[data-color="blue"] { background:rgba(52,152,219,.85); }
.margin-note-dot[data-color="red"] { background:rgba(230,57,70,.85); }
.margin-note-text {
font-size:11px; line-height:1.4; padding:5px 7px; border-radius:3px;
background:var(--bg-card,#1a1a1a); border-left:3px solid var(--muted,#888);
box-shadow:1px 2px 5px rgba(0,0,0,.3);
white-space:pre-wrap; word-break:break-word;
}
.margin-note-text[data-color="yellow"] { border-left-color:#f1c40f; }
.margin-note-text[data-color="green"] { border-left-color:#2ecc71; }
.margin-note-text[data-color="blue"] { border-left-color:#3498db; }
.margin-note-text[data-color="red"] { border-left-color:#e63946; }
/* Freeform notes get a paper-like tint, like a post-it note pinned to the text */
.margin-note-text.margin-note-freeform {
border-left-color:#e6c229; background:rgba(241,196,15,.13);
}
.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.open { width:150px; }
.reader-margin-title { display:none; }
}

View file

@ -4027,6 +4027,10 @@ function closeReader() {
const marginMarkersEl = $('reader-margin-markers');
if (marginMarkersEl) marginMarkersEl.innerHTML = '';
// Disarm marker mode
markerModeActive = false;
$('reader-marker-btn')?.classList.remove('active');
// Clear search before wiping content
clearReaderSearch();
@ -4194,10 +4198,13 @@ function applyReaderSettings(isPdf) {
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
// Annotations (marker mode + margin panel) are 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();
const markerBtn = $('reader-marker-btn');
if (markerBtn) markerBtn.style.display = isPdf ? 'none' : '';
if (isPdf && markerModeActive) toggleMarkerMode();
}
function toggleSettingsPanel() {
@ -5043,18 +5050,19 @@ function resolveAnchorRange(h) {
}
function renderHighlight(h) {
// Freeform notes carry no in-text mark — their anchor exists purely to
// position the note card in the margin (resolveAnchorRange handles that
// directly from the stored xpath, no DOM wrapper needed).
if (h.type === 'note') return;
const range = resolveAnchorRange(h);
if (!range) return;
try {
const mark = document.createElement('mark');
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) {}
}
@ -5103,8 +5111,19 @@ function buildEpubAnchor(range) {
};
}
// Marker mode — armed via the pen button in the reader header. Text selection
// only opens the highlight popover while this is active, so normal selection
// (e.g. to copy text) isn't hijacked, and the feature has a discoverable
// on/off switch instead of being an invisible "just select text" gesture.
let markerModeActive = false;
function toggleMarkerMode() {
markerModeActive = !markerModeActive;
$('reader-marker-btn')?.classList.toggle('active', markerModeActive);
}
function handleReaderSelection(e) {
// If clicking an existing highlight, show tooltip
// Clicking an existing highlight always shows its tooltip, regardless of marker mode
const hlMark = e.target.closest('.epub-highlight');
if (hlMark) {
dismissHighlightPopover();
@ -5114,6 +5133,8 @@ function handleReaderSelection(e) {
return;
}
if (!markerModeActive) return;
dismissHighlightPopover();
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
@ -5137,11 +5158,25 @@ function showHighlightPopover(range) {
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Red">A</button>
<button class="hl-note-btn" title="Add note"></button>
`;
popover.style.top = (rect.top + window.scrollY - 44) + 'px';
popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px';
popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot
document.body.appendChild(popover);
currentHighlightPopover = popover;
// Clamp within the viewport — a selection near the top/edge of the page must
// not produce an invisible, off-screen popover (flip below when there's no
// room above; keep it horizontally on-screen).
const pRect = popover.getBoundingClientRect();
const minTop = window.scrollY + 8;
const maxTop = window.scrollY + window.innerHeight - pRect.height - 8;
let top = rect.top + window.scrollY - 44;
if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below
top = Math.max(minTop, Math.min(top, maxTop));
let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2;
left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8));
popover.style.top = top + 'px';
popover.style.left = left + 'px';
popover.style.visibility = '';
// Store range info before selection is cleared
const savedRange = range.cloneRange();
@ -5171,11 +5206,22 @@ function showHighlightTooltip(markEl, h) {
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Delete</button>
</div>
`;
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = (rect.left + window.scrollX) + 'px';
popover.style.visibility = 'hidden';
document.body.appendChild(popover);
currentHighlightPopover = popover;
const pRect = popover.getBoundingClientRect();
const minTop = window.scrollY + 8;
const maxTop = window.scrollY + window.innerHeight - pRect.height - 8;
let top = rect.bottom + window.scrollY + 4;
if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below
top = Math.max(minTop, Math.min(top, maxTop));
let left = rect.left + window.scrollX;
left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8));
popover.style.top = top + 'px';
popover.style.left = left + 'px';
popover.style.visibility = '';
popover.addEventListener('click', ev => {
const editBtn = ev.target.closest('[data-hl-edit-note]');
const delBtn = ev.target.closest('[data-hl-delete]');
@ -5248,6 +5294,7 @@ function openNoteEditor(h) {
const text = (body.querySelector('#hl-note-input')?.value || '').trim();
h.note = text;
highlightsDirty = true;
_repositionMarginMarkers();
debounceSaveHighlights();
closeSidebar();
});
@ -5301,6 +5348,11 @@ function toggleAnnotationsMargin() {
}
}
// Renders each highlight/note at its text position in the margin. Entries
// with note text become a readable "post-it" card (the whole point of the
// margin — you can read notes there, not just see that one exists); a plain
// highlight with no note is just a small color dot. Cards are stacked
// top-to-bottom so overlapping anchors don't overlap visually.
function _repositionMarginMarkers() {
const markersEl = $('reader-margin-markers');
if (!markersEl) return;
@ -5308,32 +5360,45 @@ function _repositionMarginMarkers() {
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
const areaRect = markersEl.getBoundingClientRect();
const entries = [];
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';
const y = rect.top + rect.height / 2 - areaRect.top;
if (y < -60 || y > areaRect.height + 60) continue; // off-screen band
entries.push({h, y});
}
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);
entries.sort((a, b) => a.y - b.y);
let nextTop = -Infinity;
const GAP = 4;
for (const {h, y} of entries) {
const hasText = !!h.note;
const el = document.createElement('div');
el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot');
el.dataset.highlightId = h.id;
if (h.type === 'note') {
if (hasText) el.classList.add('margin-note-freeform');
} else {
el.dataset.color = h.color || 'yellow';
}
if (hasText) el.textContent = h.note;
const desiredTop = hasText ? y - 8 : y - 6;
const top = Math.max(desiredTop, nextTop);
el.style.top = top + 'px';
markersEl.appendChild(el);
const measured = el.getBoundingClientRect().height || (hasText ? 20 : 12);
nextTop = top + measured + GAP;
}
}
function handleMarginClick(e) {
const markerBtn = e.target.closest('.margin-marker');
const markerBtn = e.target.closest('.margin-note');
if (markerBtn) {
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
if (h) showHighlightTooltip(markerBtn, h);

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use.
*/
const CACHE = 'diora-v22';
const CACHE = 'diora-v23';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',

View file

@ -343,6 +343,7 @@
<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-marker-btn" onclick="toggleMarkerMode()" title="Mark text"></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" onclick="closeReader()" title="Close (Esc)"></button>