diff --git a/static/css/app.css b/static/css/app.css index 1ba65ff..6278e2a 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1800,12 +1800,12 @@ 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. */ +/* Margin ("page margin") panel — left of the reader content. Highlights without + a note show as a small color dot; anything with note text (freeform notes or + a highlight+note) shows as a readable little "post-it" card, positioned next + to the text height it belongs to. Click empty space to place a new freeform + note anchored to that line. Freeform notes have NO in-text mark — the anchor + is purely positional, only ever visible here in the margin. */ .reader-body-row { display:flex; flex:1; min-height:0; } .reader-margin { width:0; flex-shrink:0; overflow:hidden; @@ -1813,28 +1813,40 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) border-right:1px solid var(--border); background:var(--bg); transition:width 0.25s ease; } -.reader-margin.open { width:72px; } +.reader-margin.open { width:220px; } .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; +.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; } + +.margin-note { position:absolute; left:8px; right:8px; cursor:pointer; } + +.margin-note-dot { left:8px; right:auto; width:12px; height:12px; border-radius:50%; } +.margin-note-dot[data-color="yellow"] { background:rgba(241,196,15,.85); } +.margin-note-dot[data-color="green"] { background:rgba(46,204,113,.85); } +.margin-note-dot[data-color="blue"] { background:rgba(52,152,219,.85); } +.margin-note-dot[data-color="red"] { background:rgba(230,57,70,.85); } + +.margin-note-text { + font-size:11px; line-height:1.4; padding:5px 7px; border-radius:3px; + background:var(--bg-card,#1a1a1a); border-left:3px solid var(--muted,#888); + box-shadow:1px 2px 5px rgba(0,0,0,.3); + white-space:pre-wrap; word-break:break-word; +} +.margin-note-text[data-color="yellow"] { border-left-color:#f1c40f; } +.margin-note-text[data-color="green"] { border-left-color:#2ecc71; } +.margin-note-text[data-color="blue"] { border-left-color:#3498db; } +.margin-note-text[data-color="red"] { border-left-color:#e63946; } +/* Freeform notes get a paper-like tint, like a post-it note pinned to the text */ +.margin-note-text.margin-note-freeform { + border-left-color:#e6c229; background:rgba(241,196,15,.13); } -.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.open { width:150px; } .reader-margin-title { display:none; } } diff --git a/static/js/app.js b/static/js/app.js index 4c63dcc..7c0a628 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4027,6 +4027,10 @@ function closeReader() { const marginMarkersEl = $('reader-margin-markers'); if (marginMarkersEl) marginMarkersEl.innerHTML = ''; + // Disarm marker mode + markerModeActive = false; + $('reader-marker-btn')?.classList.remove('active'); + // Clear search before wiping content clearReaderSearch(); @@ -4194,10 +4198,13 @@ function applyReaderSettings(isPdf) { 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 + // Annotations (marker mode + margin panel) are 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(); + const markerBtn = $('reader-marker-btn'); + if (markerBtn) markerBtn.style.display = isPdf ? 'none' : ''; + if (isPdf && markerModeActive) toggleMarkerMode(); } function toggleSettingsPanel() { @@ -5043,18 +5050,19 @@ function resolveAnchorRange(h) { } function renderHighlight(h) { + // Freeform notes carry no in-text mark — their anchor exists purely to + // position the note card in the margin (resolveAnchorRange handles that + // directly from the stored xpath, no DOM wrapper needed). + if (h.type === 'note') return; + const range = resolveAnchorRange(h); if (!range) return; try { const mark = document.createElement('mark'); mark.dataset.highlightId = h.id; - if (h.type === 'note') { - mark.className = 'epub-note-anchor'; - } else { - mark.className = 'epub-highlight'; - mark.dataset.color = h.color || 'yellow'; - } + mark.className = 'epub-highlight'; + mark.dataset.color = h.color || 'yellow'; range.surroundContents(mark); } catch (e) {} } @@ -5103,8 +5111,19 @@ function buildEpubAnchor(range) { }; } +// Marker mode — armed via the pen button in the reader header. Text selection +// only opens the highlight popover while this is active, so normal selection +// (e.g. to copy text) isn't hijacked, and the feature has a discoverable +// on/off switch instead of being an invisible "just select text" gesture. +let markerModeActive = false; + +function toggleMarkerMode() { + markerModeActive = !markerModeActive; + $('reader-marker-btn')?.classList.toggle('active', markerModeActive); +} + function handleReaderSelection(e) { - // If clicking an existing highlight, show tooltip + // Clicking an existing highlight always shows its tooltip, regardless of marker mode const hlMark = e.target.closest('.epub-highlight'); if (hlMark) { dismissHighlightPopover(); @@ -5114,6 +5133,8 @@ function handleReaderSelection(e) { return; } + if (!markerModeActive) return; + dismissHighlightPopover(); const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return; @@ -5137,11 +5158,25 @@ function showHighlightPopover(range) { `; - popover.style.top = (rect.top + window.scrollY - 44) + 'px'; - popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px'; + popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot document.body.appendChild(popover); currentHighlightPopover = popover; + // Clamp within the viewport — a selection near the top/edge of the page must + // not produce an invisible, off-screen popover (flip below when there's no + // room above; keep it horizontally on-screen). + const pRect = popover.getBoundingClientRect(); + const minTop = window.scrollY + 8; + const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; + let top = rect.top + window.scrollY - 44; + if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below + top = Math.max(minTop, Math.min(top, maxTop)); + let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2; + left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); + popover.style.top = top + 'px'; + popover.style.left = left + 'px'; + popover.style.visibility = ''; + // Store range info before selection is cleared const savedRange = range.cloneRange(); @@ -5171,11 +5206,22 @@ function showHighlightTooltip(markEl, h) { `; - popover.style.top = (rect.bottom + window.scrollY + 4) + 'px'; - popover.style.left = (rect.left + window.scrollX) + 'px'; + popover.style.visibility = 'hidden'; document.body.appendChild(popover); currentHighlightPopover = popover; + const pRect = popover.getBoundingClientRect(); + const minTop = window.scrollY + 8; + const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; + let top = rect.bottom + window.scrollY + 4; + if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below + top = Math.max(minTop, Math.min(top, maxTop)); + let left = rect.left + window.scrollX; + left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); + popover.style.top = top + 'px'; + popover.style.left = left + 'px'; + popover.style.visibility = ''; + popover.addEventListener('click', ev => { const editBtn = ev.target.closest('[data-hl-edit-note]'); const delBtn = ev.target.closest('[data-hl-delete]'); @@ -5248,6 +5294,7 @@ function openNoteEditor(h) { const text = (body.querySelector('#hl-note-input')?.value || '').trim(); h.note = text; highlightsDirty = true; + _repositionMarginMarkers(); debounceSaveHighlights(); closeSidebar(); }); @@ -5301,6 +5348,11 @@ function toggleAnnotationsMargin() { } } +// Renders each highlight/note at its text position in the margin. Entries +// with note text become a readable "post-it" card (the whole point of the +// margin — you can read notes there, not just see that one exists); a plain +// highlight with no note is just a small color dot. Cards are stacked +// top-to-bottom so overlapping anchors don't overlap visually. function _repositionMarginMarkers() { const markersEl = $('reader-margin-markers'); if (!markersEl) return; @@ -5308,32 +5360,45 @@ function _repositionMarginMarkers() { if (!readerAnnotationsMarginOpen || currentPdfDoc) return; const areaRect = markersEl.getBoundingClientRect(); + const entries = []; 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 y = rect.top + rect.height / 2 - areaRect.top; + if (y < -60 || y > areaRect.height + 60) continue; // off-screen band + entries.push({h, y}); + } + entries.sort((a, b) => a.y - b.y); - const btn = document.createElement('button'); - btn.className = 'margin-marker' + (h.type === 'note' ? ' margin-marker-note' : ''); + let nextTop = -Infinity; + const GAP = 4; + for (const {h, y} of entries) { + const hasText = !!h.note; + const el = document.createElement('div'); + el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot'); + el.dataset.highlightId = h.id; if (h.type === 'note') { - btn.textContent = '✎'; + if (hasText) el.classList.add('margin-note-freeform'); } else { - btn.dataset.color = h.color || 'yellow'; + el.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); + if (hasText) el.textContent = h.note; + + const desiredTop = hasText ? y - 8 : y - 6; + const top = Math.max(desiredTop, nextTop); + el.style.top = top + 'px'; + markersEl.appendChild(el); + + const measured = el.getBoundingClientRect().height || (hasText ? 20 : 12); + nextTop = top + measured + GAP; } } function handleMarginClick(e) { - const markerBtn = e.target.closest('.margin-marker'); + const markerBtn = e.target.closest('.margin-note'); if (markerBtn) { const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); if (h) showHighlightTooltip(markerBtn, h); diff --git a/static/js/sw.js b/static/js/sw.js index e92d9cd..e637b77 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-v22'; +const CACHE = 'diora-v23'; 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 15537e3..a9c65f4 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -343,6 +343,7 @@ +