Reader: Notizen auf Mobile schnell setzbar statt über Randspalte (SW v32)
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:
parent
76068f6ed8
commit
d3c435dd59
4 changed files with 198 additions and 46 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
202
static/js/app.js
202
static/js/app.js
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue