diff --git a/static/css/app.css b/static/css/app.css index 89a8b39..f3b0d56 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } diff --git a/static/js/app.js b/static/js/app.js index 1a1b1e1..37a7df8 100644 --- a/static/js/app.js +++ b/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 + // 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 ". + 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 = ''; 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 = ` +
+
+ +
+ + +
+
+ `; + 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); }); } diff --git a/static/js/sw.js b/static/js/sw.js index 41ab2c4..a18e60a 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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', diff --git a/templates/radio/player.html b/templates/radio/player.html index 2f36e86..931db3e 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -348,6 +348,7 @@ + @@ -357,7 +358,7 @@
Notes - +