diff --git a/static/css/app.css b/static/css/app.css index d7246ec..1ba65ff 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1800,6 +1800,44 @@ 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. */ +.reader-body-row { display:flex; flex:1; min-height:0; } +.reader-margin { + width:0; flex-shrink:0; overflow:hidden; + display:flex; flex-direction:column; + border-right:1px solid var(--border); background:var(--bg); + transition:width 0.25s ease; +} +.reader-margin.open { width:72px; } +.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; +} +.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-title { display:none; } +} + /* Search matches */ mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; } mark.reader-search-match.active { background:rgba(230,57,70,.7); } diff --git a/static/js/app.js b/static/js/app.js index 6fffb29..4c63dcc 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3612,7 +3612,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')) return; + if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover')) 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; // If a footnote popover is open, dismiss it instead of toggling bars if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) { dismissFootnotePopover(); @@ -3877,6 +3880,7 @@ async function openBook(bookId) { _suppressScrollJumpDetect(); requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); } + _repositionMarginMarkers(); }); _resizeObserver.observe(contentEl); } @@ -3890,12 +3894,14 @@ async function openBook(bookId) { _scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = null; _checkScrollJump(contentEl); + _repositionMarginMarkers(); }); }, {passive: true}); enterReaderImmersiveMode(); } catch (e) { + console.error('openBook failed:', e); overlay.style.display = 'none'; markBookBroken(bookId); } @@ -4014,6 +4020,13 @@ function closeReader() { } _currentPositionAnchor = ''; + // Close the margin panel + readerAnnotationsMarginOpen = false; + const marginEl = $('reader-margin'); + if (marginEl) marginEl.classList.remove('open'); + const marginMarkersEl = $('reader-margin-markers'); + if (marginMarkersEl) marginMarkersEl.innerHTML = ''; + // Clear search before wiping content clearReaderSearch(); @@ -4180,6 +4193,11 @@ function applyReaderSettings(isPdf) { // PDF invert 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 + const marginBtn = $('reader-margin-btn'); + if (marginBtn) marginBtn.style.display = isPdf ? 'none' : ''; + if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin(); } function toggleSettingsPanel() { @@ -4224,7 +4242,7 @@ function toggleSettingsPanel() { `; } - overlay.insertBefore(panel, contentEl); + overlay.insertBefore(panel, $('reader-body-row')); if (!isPdf) { const fontRange = panel.querySelector('#rs-font'); @@ -4777,7 +4795,7 @@ function toggleReaderSearch() { `; - overlay.insertBefore(bar, contentEl); + overlay.insertBefore(bar, $('reader-body-row')); const input = bar.querySelector('#reader-search-input'); input.focus(); @@ -4982,11 +5000,15 @@ function applyHighlightsToContent() { for (const h of currentHighlights) { try { renderHighlight(h); } catch (e) {} } + _repositionMarginMarkers(); } -function renderHighlight(h) { +// Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by +// rendering, margin-marker positioning and export ordering — all three need the +// same "where in the current DOM is this annotation" answer. +function resolveAnchorRange(h) { const contentEl = $('reader-content'); - if (!contentEl || !h.anchor) return; + if (!contentEl || !h.anchor) return null; const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`) || contentEl; @@ -5017,13 +5039,22 @@ function renderHighlight(h) { } } + return range; +} + +function renderHighlight(h) { + const range = resolveAnchorRange(h); if (!range) return; try { const mark = document.createElement('mark'); - mark.className = 'epub-highlight'; mark.dataset.highlightId = h.id; - mark.dataset.color = h.color || 'yellow'; + 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) {} } @@ -5168,6 +5199,7 @@ function createHighlight(color, range) { const anchor = buildEpubAnchor(range); const h = { id: crypto.randomUUID(), + type: 'highlight', anchor, color, note: '', @@ -5178,6 +5210,7 @@ function createHighlight(color, range) { window.getSelection()?.removeAllRanges(); dismissHighlightPopover(); renderHighlight(h); + _repositionMarginMarkers(); debounceSaveHighlights(); } @@ -5185,6 +5218,7 @@ function createHighlightWithNote(range) { const anchor = buildEpubAnchor(range); const h = { id: crypto.randomUUID(), + type: 'highlight', anchor, color: 'yellow', note: '', @@ -5195,9 +5229,12 @@ function createHighlightWithNote(range) { window.getSelection()?.removeAllRanges(); dismissHighlightPopover(); renderHighlight(h); + _repositionMarginMarkers(); openNoteEditor(h); } +// Freeform note, anchored via the margin panel rather than a text selection — +// see createFreeformNote() below. Reused for editing notes on existing highlights too. function openNoteEditor(h) { openSidebar('Edit note', ` @@ -5230,6 +5267,7 @@ function deleteHighlight(id) { parent.removeChild(mark); } } + _repositionMarginMarkers(); debounceSaveHighlights(); } @@ -5240,6 +5278,204 @@ function dismissHighlightPopover() { } } +// --------------------------------------------------------------------------- +// Margin panel — a "page margin" strip left of the reader content. Shows small +// markers next to the on-screen highlights/notes, and lets you click empty +// space to anchor a freeform note at that line's height (EPUB only). +// --------------------------------------------------------------------------- +let readerAnnotationsMarginOpen = false; + +function toggleAnnotationsMargin() { + const el = $('reader-margin'); + const markersEl = $('reader-margin-markers'); + if (!el || !markersEl || currentPdfDoc) return; + readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen; + el.classList.toggle('open', readerAnnotationsMarginOpen); + if (readerAnnotationsMarginOpen) { + // Reposition once now (pre-transition) and once more after the width + // transition settles, since content reflow can shift line heights. + _repositionMarginMarkers(); + el.addEventListener('transitionend', _repositionMarginMarkers, {once: true}); + } else { + markersEl.innerHTML = ''; + } +} + +function _repositionMarginMarkers() { + const markersEl = $('reader-margin-markers'); + if (!markersEl) return; + markersEl.innerHTML = ''; + if (!readerAnnotationsMarginOpen || currentPdfDoc) return; + + const areaRect = markersEl.getBoundingClientRect(); + 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'; + } + 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); + } +} + +function handleMarginClick(e) { + const markerBtn = e.target.closest('.margin-marker'); + if (markerBtn) { + const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); + if (h) showHighlightTooltip(markerBtn, h); + return; + } + + // Empty space in the markers area → place a new freeform note anchored to + // the first word of whatever line sits at this click height. + if (!readerAnnotationsMarginOpen || currentPdfDoc) return; + const markersEl = $('reader-margin-markers'); + if (!markersEl || !markersEl.contains(e.target)) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + + const x = contentEl.getBoundingClientRect().left + 4; + const range = _wordRangeAtPoint(x, e.clientY); + if (range) createFreeformNote(range); +} + +// Finds the word starting at (or just after) the given viewport point, by +// probing near the left edge of the reader content at that height — since +// text is left-aligned, that lands at (or very near) the start of the line. +function _wordRangeAtPoint(x, y) { + let caret = null; + if (document.caretRangeFromPoint) { + caret = document.caretRangeFromPoint(x, y); + } else if (document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(x, y); + if (pos) { + caret = document.createRange(); + caret.setStart(pos.offsetNode, pos.offset); + } + } + if (!caret) return null; + + const contentEl = $('reader-content'); + if (!contentEl || !contentEl.contains(caret.startContainer)) return null; + + let node = caret.startContainer; + let offset = caret.startOffset; + if (node.nodeType !== Node.TEXT_NODE) { + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); + walker.currentNode = node; + node = walker.nextNode(); + if (!node) return null; + offset = 0; + } + + const text = node.textContent; + let start = offset; + while (start < text.length && /\s/.test(text[start])) start++; + if (start >= text.length) return null; + let wordStart = start; + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; + let wordEnd = start; + while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; + if (wordEnd <= wordStart) return null; + + const range = document.createRange(); + range.setStart(node, wordStart); + range.setEnd(node, wordEnd); + return range; +} + +function createFreeformNote(range) { + const anchor = buildEpubAnchor(range); + const h = { + id: crypto.randomUUID(), + type: 'note', + anchor, + color: null, + note: '', + createdAt: new Date().toISOString(), + }; + currentHighlights.push(h); + highlightsDirty = true; + renderHighlight(h); + _repositionMarginMarkers(); + debounceSaveHighlights(); + openNoteEditor(h); +} + +// --------------------------------------------------------------------------- +// Export annotations — walks all highlights/notes of the current book in +// reading order and offers them as a downloadable plain-text file. +// --------------------------------------------------------------------------- +function _tocTitleForChapterSrc(chapterSrc) { + if (!chapterSrc) return ''; + const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc); + return entry ? entry.label : ''; +} + +function exportAnnotations() { + if (!currentHighlights.length) { + customAlert('No highlights or notes in this book yet.'); + return; + } + + const resolved = []; + for (const h of currentHighlights) { + let range; + try { range = resolveAnchorRange(h); } catch (e) { range = null; } + resolved.push({h, range}); + } + resolved.sort((a, b) => { + if (!a.range || !b.range) return 0; + const pos = a.range.compareBoundaryPoints(Range.START_TO_START, b.range); + return pos; + }); + + const titleEl = $('reader-title'); + const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0]; + const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, '']; + + let lastChapter = null; + for (const {h} of resolved) { + const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || ''; + if (chapterTitle !== lastChapter) { + lines.push(`── ${chapterTitle || 'Untitled section'} ──`); + lastChapter = chapterTitle; + } + if (h.type === 'note') { + lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`); + if (h.note) lines.push(` ${h.note}`); + } else { + lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`); + if (h.note) lines.push(` Note: ${h.note}`); + } + lines.push(''); + } + + const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-annotations.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + // --------------------------------------------------------------------------- // Footnote popover // --------------------------------------------------------------------------- @@ -5467,5 +5703,19 @@ function openRadioSidebar() { showFootnotePopover(link); } }); + + // Text selection → highlight color/note popover + _readerContentEl.addEventListener('mouseup', handleReaderSelection); + _readerContentEl.addEventListener('touchend', e => { + const target = e.target; + setTimeout(() => handleReaderSelection({target}), 50); + }); + } + + // Margin ("page margin") panel — click a marker to view/edit, click empty + // space to anchor a new freeform note at that line's height + const _readerMarginEl = $('reader-margin'); + if (_readerMarginEl) { + _readerMarginEl.addEventListener('click', handleMarginClick); } })(); diff --git a/static/js/sw.js b/static/js/sw.js index 7d2d734..e92d9cd 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-v21'; +const CACHE = 'diora-v22'; 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 abfc16f..15537e3 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -343,11 +343,21 @@ + -
+
+ +
+