Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v23)
Drei Nachbesserungen am Annotationen-Feature nach Live-Test: - Neuer Stift-Button (Marker-Modus) in der Kopfleiste: Textauswahl löst das Highlight-Popup nur noch aus, wenn der Modus aktiv ist — vorher war die Funktion unauffindbar (impliziter "einfach Text markieren"-Gestus ohne sichtbaren Einstiegspunkt) und hätte jede Textauswahl (auch zum Kopieren) gekapert. - Freiform-Notizen bekommen keine Textdekoration mehr im Lesefluss — der Anker dient nur der Positionierung im Rand, nicht der optischen Markierung. - Die Margin-Sidebar zeigt Notiztext jetzt direkt lesbar als kleine Post-it-Karte an der zugehörigen Stelle (mit Stapel-Logik gegen Überlappung), statt nur ein abstraktes Symbol zum Anklicken. Reine Highlights ohne Notiz bleiben ein kleiner Farbpunkt. - Highlight-Popover/Tooltip klammern ihre Position jetzt hart an den Viewport, damit eine Auswahl nahe Seitenrand nicht unsichtbar außerhalb des sichtbaren Bereichs landet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
090aec9c60
commit
bc180daab0
4 changed files with 122 additions and 44 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
115
static/js/app.js
115
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';
|
||||
}
|
||||
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) {
|
|||
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Red">A</button>
|
||||
<button class="hl-note-btn" title="Add note">✎</button>
|
||||
`;
|
||||
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) {
|
|||
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
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 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';
|
||||
const y = rect.top + rect.height / 2 - areaRect.top;
|
||||
if (y < -60 || y > areaRect.height + 60) continue; // off-screen band
|
||||
entries.push({h, y});
|
||||
}
|
||||
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);
|
||||
entries.sort((a, b) => a.y - b.y);
|
||||
|
||||
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') {
|
||||
if (hasText) el.classList.add('margin-note-freeform');
|
||||
} else {
|
||||
el.dataset.color = h.color || 'yellow';
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -343,6 +343,7 @@
|
|||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||
<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-marker-btn" onclick="toggleMarkerMode()" title="Mark text">✒</button>
|
||||
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes & highlights">✎</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue