Reader: Notizen auf Mobile schnell setzbar statt über Randspalte (SW v32)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 15s

Auf schmalen Screens saß der Marker-Modus-Button bisher nur in der 150px-
Randspalte — man musste sie erst öffnen (und damit den ohnehin knappen
Lesebereich verkleinern), um überhaupt markieren zu können. Jetzt zusätzlich
ein Marker-Button direkt im Haupt-Header (nur <=600px sichtbar, Desktop
bleibt bei der bisherigen Anordnung in der Notes-Leiste).

Im Marker-Modus legt ein einfacher Tap auf reinen Lesetext (ohne Auswahl)
jetzt direkt eine freie Notiz an der Wortposition an — man muss nicht mehr
den exakten Klick-Punkt in der Randspalte treffen. Das Bearbeiten der
Notiz läuft auf schmalen Viewports über ein neues Bottom-Sheet (volle
Breite) statt über das Inline-Textfeld in der 150px-Spalte, das auf dem
Handy-Keyboard unangenehm zu bedienen ist.

Notiz-Objekte werden jetzt erst bei tatsächlich eingegebenem Text angelegt
und gespeichert (vorher: sofort beim Setzen des Ankers) — verhindert, dass
ein abgebrochener/leerer Tap eine Ghost-Notiz in currentHighlights
hinterlässt. Gilt für alle Eingabewege (Bottom-Sheet UND die bestehende
Inline-Randspalten-Bearbeitung), per Playwright verifiziert inkl. Escape-
Cancel auf Desktop.

Priorität laut Nutzer: Notizen-Erfassung muss auf Mobile gut funktionieren;
dass bestehende Notizen dort weniger prominent sichtbar sind, ist explizit
in Ordnung — die vertiefte Auseinandersetzung mit Notizen bleibt eine
Desktop-Aufgabe.

Verifiziert per Playwright (390×844 mobil + 1280×900 Desktop-Regression):
Marker-Button-Sichtbarkeit, Bottom-Sheet-Flow (Erstellen/Abbrechen/Speichern,
kein Ghost-Eintrag), reale Text-Selektion + Highlight+Notiz weiterhin
funktionsfähig auf Mobile, Desktop-Workflow (Randspalte, Inline-Editor,
Escape-Cancel, Blur-Commit) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 20:13:55 +02:00
parent 76068f6ed8
commit d3c435dd59
4 changed files with 198 additions and 46 deletions

View file

@ -1480,6 +1480,8 @@ body.dnd-mode .timer-display {
padding: 4px 6px; font-size: 0.82rem; cursor: pointer; padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
} }
.reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) { @media (max-width: 600px) {
.sidebar { width: 100vw; } .sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; } .podcast-seek-bar { padding: 0 6px; }
@ -1494,6 +1496,12 @@ body.dnd-mode .timer-display {
.reader-header-actions { gap: 4px; } .reader-header-actions { gap: 4px; }
.reader-progress-wrap { display: none; } .reader-progress-wrap { display: none; }
.reader-content { padding: 16px 10px; } .reader-content { padding: 16px 10px; }
/* On a phone, opening the 150px margin just to reach the marker-mode toggle
eats too much of an already-narrow reading column surface it directly
in the main header instead (desktop keeps it only in the margin header,
grouped with "Notes", per earlier feedback against back-and-forth). */
.reader-marker-btn-mobile { display: inline-block; }
} }
/* ========================================================= /* =========================================================
@ -1880,6 +1888,35 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
box-shadow:1px 2px 5px rgba(0,0,0,.4); resize:vertical; box-shadow:1px 2px 5px rgba(0,0,0,.4); resize:vertical;
} }
/* Note bottom sheet the mobile alternative to the inline margin editor.
Typing a note into a 150px-wide margin column is unpleasant on a phone
keyboard, and getting there first requires opening that column at all, so
on narrow viewports note editing uses this full-width sheet instead (see
_isMobileReader() / _openNoteEditor() in app.js). Existing notes staying
less discoverable on mobile is an accepted tradeoff reviewing them in
depth is expected to happen on desktop; setting them fast is what matters
here. */
.note-bottom-sheet { position:fixed; inset:0; z-index:650; }
.note-bottom-sheet-backdrop {
position:absolute; inset:0; background:rgba(0,0,0,.5);
opacity:0; transition:opacity .2s ease; pointer-events:none;
}
.note-bottom-sheet.open .note-bottom-sheet-backdrop { opacity:1; pointer-events:auto; }
.note-bottom-sheet-panel {
position:absolute; left:0; right:0; bottom:0;
background:var(--surface,#111); border-top:1px solid var(--border,#333);
border-radius:12px 12px 0 0; padding:12px 14px calc(14px + env(safe-area-inset-bottom));
transform:translateY(100%); transition:transform .25s ease;
display:flex; flex-direction:column; gap:10px;
}
.note-bottom-sheet.open .note-bottom-sheet-panel { transform:translateY(0); }
.note-bottom-sheet-textarea {
width:100%; min-height:110px; font-size:15px; line-height:1.4; padding:8px 10px;
border-radius:var(--radius); border:1px solid var(--border,#333);
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee); resize:vertical;
}
.note-bottom-sheet-actions { display:flex; justify-content:flex-end; gap:8px; }
@media (max-width: 600px) { @media (max-width: 600px) {
.reader-margin.open { width:150px; } .reader-margin.open { width:150px; }
.reader-margin-title { display:none; } .reader-margin-title { display:none; }

View file

@ -3691,7 +3691,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, #reader-margin, #highlight-popover')) return; if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet')) return;
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
// don't also toggle the immersive bars underneath.
if (markerModeActive) return;
// Don't toggle bars while the user has just finished selecting text (e.g. to highlight it) // Don't toggle bars while the user has just finished selecting text (e.g. to highlight it)
const sel = window.getSelection(); const sel = window.getSelection();
if (sel && !sel.isCollapsed) return; if (sel && !sel.isCollapsed) return;
@ -4113,7 +4116,8 @@ function closeReader() {
// Disarm marker mode // Disarm marker mode
markerModeActive = false; markerModeActive = false;
$('reader-marker-btn')?.classList.remove('active'); document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.remove('active'));
dismissNoteBottomSheet();
// Clear search before wiping content // Clear search before wiping content
clearReaderSearch(); clearReaderSearch();
@ -4287,8 +4291,7 @@ function applyReaderSettings(isPdf) {
const marginBtn = $('reader-margin-btn'); const marginBtn = $('reader-margin-btn');
if (marginBtn) marginBtn.style.display = isPdf ? 'none' : ''; if (marginBtn) marginBtn.style.display = isPdf ? 'none' : '';
if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin(); if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin();
const markerBtn = $('reader-marker-btn'); document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; });
if (markerBtn) markerBtn.style.display = isPdf ? 'none' : '';
if (isPdf && markerModeActive) toggleMarkerMode(); if (isPdf && markerModeActive) toggleMarkerMode();
} }
@ -5221,7 +5224,17 @@ let markerModeActive = false;
function toggleMarkerMode() { function toggleMarkerMode() {
markerModeActive = !markerModeActive; markerModeActive = !markerModeActive;
$('reader-marker-btn')?.classList.toggle('active', markerModeActive); document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive));
}
// Note editing/creation happens in the margin column on desktop, but a note
// bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a
// 150px margin strip on a phone keyboard is unpleasant, and opening that
// column at all is exactly the friction we're trying to remove for mobile
// note-taking. Reviewing existing notes in depth is expected to stay a
// desktop activity; this only affects where the editor for a note appears.
function _isMobileReader() {
return window.innerWidth <= 600;
} }
function handleReaderSelection(e) { function handleReaderSelection(e) {
@ -5236,16 +5249,33 @@ function handleReaderSelection(e) {
} }
if (!markerModeActive) return; if (!markerModeActive) return;
// Don't hijack taps on real interactive content (footnote refs, external
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
// strips its href but the tag itself stays); those aren't actually
// clickable and must not block note-taking, so check for a surviving
// href/footnote-ref rather than just "is inside some <a>".
if (e.target.closest('button, [data-footnote-ref], a[href]')) return;
dismissHighlightPopover(); dismissHighlightPopover();
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return; if (sel && !sel.isCollapsed && sel.rangeCount) {
const range = sel.getRangeAt(0); const range = sel.getRangeAt(0);
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return; if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return;
if (range.toString().trim().length === 0) return; if (range.toString().trim().length === 0) return;
showHighlightPopover(range);
return;
}
showHighlightPopover(range); // A plain tap with nothing selected — in marker mode this means "drop a
// freeform note here" instead of doing nothing, so a note-only annotation
// never depends on finding the margin's precise click target (especially
// hard on a narrow phone screen — see createFreeformNote/_openNoteEditor).
if (typeof e.clientX === 'number' && typeof e.clientY === 'number') {
const range = _wordRangeAtPoint(e.clientX, e.clientY);
if (range) createFreeformNote(range);
}
} }
function showHighlightPopover(range) { function showHighlightPopover(range) {
@ -5329,7 +5359,7 @@ function showHighlightTooltip(markEl, h) {
const delBtn = ev.target.closest('[data-hl-delete]'); const delBtn = ev.target.closest('[data-hl-delete]');
if (editBtn && h) { if (editBtn && h) {
dismissHighlightPopover(); dismissHighlightPopover();
_openMarginAndEditNote(h); _openNoteEditor(h);
} }
if (delBtn && h) { if (delBtn && h) {
dismissHighlightPopover(); dismissHighlightPopover();
@ -5378,16 +5408,23 @@ function createHighlightWithNote(range) {
dismissHighlightPopover(); dismissHighlightPopover();
renderHighlight(h); renderHighlight(h);
_repositionMarginMarkers(); _repositionMarginMarkers();
_openMarginAndEditNote(h); _openNoteEditor(h);
} }
// Every "add/edit a note" entry point routes here — notes are only ever // Every "add/edit a note" entry point routes here — notes are always edited
// written where they live, in the margin, never in the separate right-hand // right where they conceptually live (the margin), never in the separate
// sidebar (that used to mean a jarring jump away from the text you're // right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is
// annotating). // too cramped to type into and a full-width bottom sheet takes over instead.
function _openMarginAndEditNote(h) { // `pending` is only set for a not-yet-created freeform note (see
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); // createFreeformNote); h is null in that case until the user actually saves
editNoteInlineInMargin(h); // text, so an empty tap doesn't leave a permanent ghost entry behind.
function _openNoteEditor(h, pending) {
if (_isMobileReader()) {
showNoteBottomSheet(h, pending);
} else {
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
editNoteInlineInMargin(h, pending);
}
} }
function deleteHighlight(id) { function deleteHighlight(id) {
@ -5508,7 +5545,7 @@ function handleMarginClick(e) {
const markerBtn = e.target.closest('.margin-note'); const markerBtn = e.target.closest('.margin-note');
if (markerBtn) { if (markerBtn) {
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
if (h) editNoteInlineInMargin(h); if (h) _openNoteEditor(h);
return; return;
} }
@ -5575,33 +5612,62 @@ function _wordRangeAtPoint(x, y) {
function createFreeformNote(range) { function createFreeformNote(range) {
const anchor = buildEpubAnchor(range); const anchor = buildEpubAnchor(range);
const h = { // Where the note's card/dot would sit in the margin, computed from the live
id: crypto.randomUUID(), // range right now (before it goes stale) — used to position the editor,
type: 'note', // whether or not the margin is actually open.
anchor, let top = 0;
color: null, const markersEl = $('reader-margin-markers');
note: '', if (markersEl) {
createdAt: new Date().toISOString(), const areaRect = markersEl.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8);
}
// The highlight object itself isn't created yet — see _openNoteEditor's
// `pending` param — so a tap that's immediately cancelled or left empty
// never leaves a ghost entry in currentHighlights.
_openNoteEditor(null, {anchor, top});
}
// Commits an edit for both note-editor UIs (inline margin + mobile bottom
// sheet): updates an existing note in place, or — only if there's actual
// text — creates and persists a not-yet-existing freeform note. An empty
// tap/cancel on a pending note must never leave a permanent ghost entry.
function _commitNoteEdit(h, pending, text) {
if (h) {
if (h.note !== text) {
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
}
return h;
}
if (!text) return null;
const newH = {
id: crypto.randomUUID(), type: 'note', anchor: pending.anchor,
color: null, note: text, createdAt: new Date().toISOString(),
}; };
currentHighlights.push(h); currentHighlights.push(newH);
highlightsDirty = true; highlightsDirty = true;
renderHighlight(h);
_repositionMarginMarkers();
debounceSaveHighlights(); debounceSaveHighlights();
editNoteInlineInMargin(h); return newH;
} }
// Edit a note's text right where it lives in the margin, instead of jumping // Edit a note's text right where it lives in the margin, instead of jumping
// to the separate right-hand sidebar — the whole point of setting a note in // to the separate right-hand sidebar — the whole point of setting a note in
// the margin is that you shouldn't have to look away from it to write it. // the margin is that you shouldn't have to look away from it to write it.
function editNoteInlineInMargin(h) { // `h` is null for a not-yet-created freeform note (see createFreeformNote);
// `pending` then carries its anchor + intended margin position instead.
function editNoteInlineInMargin(h, pending) {
const markersEl = $('reader-margin-markers'); const markersEl = $('reader-margin-markers');
if (!markersEl) return; if (!markersEl) return;
markersEl.querySelector('.margin-note-editor')?.remove(); markersEl.querySelector('.margin-note-editor')?.remove();
const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); let top = pending?.top || 0;
const top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0; if (h) {
if (existingCard) existingCard.style.display = 'none'; const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`);
top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0;
if (existingCard) existingCard.style.display = 'none';
}
const editor = document.createElement('div'); const editor = document.createElement('div');
editor.className = 'margin-note-editor'; editor.className = 'margin-note-editor';
@ -5609,15 +5675,10 @@ function editNoteInlineInMargin(h) {
editor.innerHTML = '<textarea class="margin-note-textarea"></textarea>'; editor.innerHTML = '<textarea class="margin-note-textarea"></textarea>';
markersEl.appendChild(editor); markersEl.appendChild(editor);
const textarea = editor.querySelector('textarea'); const textarea = editor.querySelector('textarea');
textarea.value = h.note || ''; textarea.value = h?.note || '';
function commit() { function commit() {
const text = textarea.value.trim(); _commitNoteEdit(h, pending, textarea.value.trim());
if (h.note !== text) {
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
}
editor.remove(); editor.remove();
_repositionMarginMarkers(); _repositionMarginMarkers();
} }
@ -5643,6 +5704,56 @@ function editNoteInlineInMargin(h) {
textarea.select(); textarea.select();
} }
// Mobile alternative to editNoteInlineInMargin — a full-width sheet instead
// of squeezing a textarea into the 150px margin column (see _openNoteEditor).
let currentNoteBottomSheet = null;
function dismissNoteBottomSheet() {
currentNoteBottomSheet?.remove();
currentNoteBottomSheet = null;
}
function showNoteBottomSheet(h, pending) {
dismissNoteBottomSheet();
const sheet = document.createElement('div');
sheet.className = 'note-bottom-sheet';
sheet.innerHTML = `
<div class="note-bottom-sheet-backdrop"></div>
<div class="note-bottom-sheet-panel">
<textarea class="note-bottom-sheet-textarea" placeholder="Notiz…"></textarea>
<div class="note-bottom-sheet-actions">
<button class="btn btn-sm" data-nbs-cancel>Abbrechen</button>
<button class="btn btn-sm btn-primary" data-nbs-save>Speichern</button>
</div>
</div>
`;
document.body.appendChild(sheet);
currentNoteBottomSheet = sheet;
const textarea = sheet.querySelector('.note-bottom-sheet-textarea');
textarea.value = h?.note || '';
function commit() {
_commitNoteEdit(h, pending, textarea.value.trim());
dismissNoteBottomSheet();
if (readerAnnotationsMarginOpen) _repositionMarginMarkers();
}
function cancel() { dismissNoteBottomSheet(); }
sheet.querySelector('[data-nbs-save]').addEventListener('click', commit);
sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel);
sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel);
textarea.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
});
requestAnimationFrame(() => {
sheet.classList.add('open');
textarea.focus();
});
}
// Same plain-text export as exportAnnotations(), but callable straight from the // Same plain-text export as exportAnnotations(), but callable straight from the
// book list (no open reader, so no live DOM/TOC to resolve percent position or // book list (no open reader, so no live DOM/TOC to resolve percent position or
// chapter titles against — falls back to the raw chapterSrc path and orders by // chapter titles against — falls back to the raw chapterSrc path and orders by
@ -6073,7 +6184,10 @@ function openRadioSidebar() {
_readerContentEl.addEventListener('mouseup', handleReaderSelection); _readerContentEl.addEventListener('mouseup', handleReaderSelection);
_readerContentEl.addEventListener('touchend', e => { _readerContentEl.addEventListener('touchend', e => {
const target = e.target; const target = e.target;
setTimeout(() => handleReaderSelection({target}), 50); const touch = e.changedTouches && e.changedTouches[0];
const clientX = touch ? touch.clientX : undefined;
const clientY = touch ? touch.clientY : undefined;
setTimeout(() => handleReaderSelection({target, clientX, clientY}), 50);
}); });
} }

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-v31'; const CACHE = 'diora-v32';
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

@ -348,6 +348,7 @@
<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-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes &amp; highlights"></button>
<button class="btn-icon marker-mode-btn reader-marker-btn-mobile" id="reader-marker-btn-mobile" onclick="toggleMarkerMode()" title="Text markieren"></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>
@ -357,7 +358,7 @@
<div class="reader-margin-header"> <div class="reader-margin-header">
<span class="reader-margin-title">Notes</span> <span class="reader-margin-title">Notes</span>
<span class="reader-margin-header-actions"> <span class="reader-margin-header-actions">
<button class="btn-icon" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text"></button> <button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text"></button>
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights &amp; notes, jump to any of them"></button> <button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights &amp; notes, jump to any of them"></button>
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file"></button> <button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file"></button>
</span> </span>