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;
}
.reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) {
.sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; }
@ -1494,6 +1496,12 @@ body.dnd-mode .timer-display {
.reader-header-actions { gap: 4px; }
.reader-progress-wrap { display: none; }
.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;
}
/* 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) {
.reader-margin.open { width:150px; }
.reader-margin-title { display:none; }

View file

@ -3691,7 +3691,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, #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)
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
@ -4113,7 +4116,8 @@ function closeReader() {
// Disarm marker mode
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
clearReaderSearch();
@ -4287,8 +4291,7 @@ function applyReaderSettings(isPdf) {
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' : '';
document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; });
if (isPdf && markerModeActive) toggleMarkerMode();
}
@ -5221,7 +5224,17 @@ let markerModeActive = false;
function toggleMarkerMode() {
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) {
@ -5236,16 +5249,33 @@ function handleReaderSelection(e) {
}
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();
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return;
if (range.toString().trim().length === 0) return;
if (sel && !sel.isCollapsed && sel.rangeCount) {
const range = sel.getRangeAt(0);
const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) 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) {
@ -5329,7 +5359,7 @@ function showHighlightTooltip(markEl, h) {
const delBtn = ev.target.closest('[data-hl-delete]');
if (editBtn && h) {
dismissHighlightPopover();
_openMarginAndEditNote(h);
_openNoteEditor(h);
}
if (delBtn && h) {
dismissHighlightPopover();
@ -5378,16 +5408,23 @@ function createHighlightWithNote(range) {
dismissHighlightPopover();
renderHighlight(h);
_repositionMarginMarkers();
_openMarginAndEditNote(h);
_openNoteEditor(h);
}
// Every "add/edit a note" entry point routes here — notes are only ever
// written where they live, in the margin, never in the separate right-hand
// sidebar (that used to mean a jarring jump away from the text you're
// annotating).
function _openMarginAndEditNote(h) {
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
editNoteInlineInMargin(h);
// Every "add/edit a note" entry point routes here — notes are always edited
// right where they conceptually live (the margin), never in the separate
// right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is
// too cramped to type into and a full-width bottom sheet takes over instead.
// `pending` is only set for a not-yet-created freeform note (see
// createFreeformNote); h is null in that case until the user actually saves
// 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) {
@ -5508,7 +5545,7 @@ function handleMarginClick(e) {
const markerBtn = e.target.closest('.margin-note');
if (markerBtn) {
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
if (h) editNoteInlineInMargin(h);
if (h) _openNoteEditor(h);
return;
}
@ -5575,33 +5612,62 @@ function _wordRangeAtPoint(x, y) {
function createFreeformNote(range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
type: 'note',
anchor,
color: null,
note: '',
createdAt: new Date().toISOString(),
// Where the note's card/dot would sit in the margin, computed from the live
// range right now (before it goes stale) — used to position the editor,
// whether or not the margin is actually open.
let top = 0;
const markersEl = $('reader-margin-markers');
if (markersEl) {
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;
renderHighlight(h);
_repositionMarginMarkers();
debounceSaveHighlights();
editNoteInlineInMargin(h);
return newH;
}
// 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
// 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');
if (!markersEl) return;
markersEl.querySelector('.margin-note-editor')?.remove();
const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`);
const top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0;
if (existingCard) existingCard.style.display = 'none';
let top = pending?.top || 0;
if (h) {
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');
editor.className = 'margin-note-editor';
@ -5609,15 +5675,10 @@ function editNoteInlineInMargin(h) {
editor.innerHTML = '<textarea class="margin-note-textarea"></textarea>';
markersEl.appendChild(editor);
const textarea = editor.querySelector('textarea');
textarea.value = h.note || '';
textarea.value = h?.note || '';
function commit() {
const text = textarea.value.trim();
if (h.note !== text) {
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
}
_commitNoteEdit(h, pending, textarea.value.trim());
editor.remove();
_repositionMarginMarkers();
}
@ -5643,6 +5704,56 @@ function editNoteInlineInMargin(h) {
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
// 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
@ -6073,7 +6184,10 @@ function openRadioSidebar() {
_readerContentEl.addEventListener('mouseup', handleReaderSelection);
_readerContentEl.addEventListener('touchend', e => {
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.
*/
const CACHE = 'diora-v31';
const CACHE = 'diora-v32';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/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-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 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" onclick="closeReader()" title="Close (Esc)"></button>
</div>
@ -357,7 +358,7 @@
<div class="reader-margin-header">
<span class="reader-margin-title">Notes</span>
<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-export-btn" onclick="exportAnnotations()" title="Export as text file"></button>
</span>