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 @@
+