Compare commits
2 commits
bc180daab0
...
3f40a4078a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f40a4078a | ||
|
|
a2f4be8e1e |
4 changed files with 168 additions and 19 deletions
|
|
@ -1820,6 +1820,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
|
||||||
}
|
}
|
||||||
.reader-margin.open .reader-margin-header { display:flex; }
|
.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-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted,#888); }
|
||||||
|
.reader-margin-header-actions { display:flex; align-items:center; gap:6px; }
|
||||||
.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; }
|
.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 { position:absolute; left:8px; right:8px; cursor:pointer; }
|
||||||
|
|
@ -1845,6 +1846,16 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
|
||||||
border-left-color:#e6c229; background:rgba(241,196,15,.13);
|
border-left-color:#e6c229; background:rgba(241,196,15,.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline note editor — appears right where the note lives in the margin, so
|
||||||
|
setting/editing a note never means jumping to a separate panel */
|
||||||
|
.margin-note-editor { position:absolute; left:8px; right:8px; z-index:1; }
|
||||||
|
.margin-note-textarea {
|
||||||
|
width:100%; min-height:52px; font-size:11px; line-height:1.4; padding:5px 7px;
|
||||||
|
border-radius:3px; border:1px solid var(--accent,#e63946);
|
||||||
|
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee);
|
||||||
|
box-shadow:1px 2px 5px rgba(0,0,0,.4); resize:vertical;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.reader-margin.open { width:150px; }
|
.reader-margin.open { width:150px; }
|
||||||
.reader-margin-title { display:none; }
|
.reader-margin-title { display:none; }
|
||||||
|
|
|
||||||
167
static/js/app.js
167
static/js/app.js
|
|
@ -5356,6 +5356,7 @@ function toggleAnnotationsMargin() {
|
||||||
function _repositionMarginMarkers() {
|
function _repositionMarginMarkers() {
|
||||||
const markersEl = $('reader-margin-markers');
|
const markersEl = $('reader-margin-markers');
|
||||||
if (!markersEl) return;
|
if (!markersEl) return;
|
||||||
|
if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit
|
||||||
markersEl.innerHTML = '';
|
markersEl.innerHTML = '';
|
||||||
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
||||||
|
|
||||||
|
|
@ -5401,7 +5402,7 @@ function handleMarginClick(e) {
|
||||||
const markerBtn = e.target.closest('.margin-note');
|
const markerBtn = e.target.closest('.margin-note');
|
||||||
if (markerBtn) {
|
if (markerBtn) {
|
||||||
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
|
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
|
||||||
if (h) showHighlightTooltip(markerBtn, h);
|
if (h) editNoteInlineInMargin(h);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5410,6 +5411,9 @@ function handleMarginClick(e) {
|
||||||
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
|
||||||
const markersEl = $('reader-margin-markers');
|
const markersEl = $('reader-margin-markers');
|
||||||
if (!markersEl || !markersEl.contains(e.target)) return;
|
if (!markersEl || !markersEl.contains(e.target)) return;
|
||||||
|
// If an editor is already open, this click just blurs/commits it (see its
|
||||||
|
// own blur handler) — it shouldn't also create a second, unrelated note.
|
||||||
|
if (markersEl.querySelector('.margin-note-editor')) return;
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
||||||
|
|
@ -5478,7 +5482,59 @@ function createFreeformNote(range) {
|
||||||
renderHighlight(h);
|
renderHighlight(h);
|
||||||
_repositionMarginMarkers();
|
_repositionMarginMarkers();
|
||||||
debounceSaveHighlights();
|
debounceSaveHighlights();
|
||||||
openNoteEditor(h);
|
editNoteInlineInMargin(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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';
|
||||||
|
|
||||||
|
const editor = document.createElement('div');
|
||||||
|
editor.className = 'margin-note-editor';
|
||||||
|
editor.style.top = top + 'px';
|
||||||
|
editor.innerHTML = '<textarea class="margin-note-textarea"></textarea>';
|
||||||
|
markersEl.appendChild(editor);
|
||||||
|
const textarea = editor.querySelector('textarea');
|
||||||
|
textarea.value = h.note || '';
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const text = textarea.value.trim();
|
||||||
|
if (h.note !== text) {
|
||||||
|
h.note = text;
|
||||||
|
highlightsDirty = true;
|
||||||
|
debounceSaveHighlights();
|
||||||
|
}
|
||||||
|
editor.remove();
|
||||||
|
_repositionMarginMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.addEventListener('blur', commit);
|
||||||
|
textarea.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
textarea.blur(); // triggers commit()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
// Stop this from bubbling to the document-level Escape handler, which
|
||||||
|
// would otherwise close the whole reader (it doesn't know we just want
|
||||||
|
// to cancel this one edit) — see the global keydown listener.
|
||||||
|
e.stopPropagation();
|
||||||
|
textarea.removeEventListener('blur', commit); // discard, don't save
|
||||||
|
editor.remove();
|
||||||
|
_repositionMarginMarkers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -5491,40 +5547,59 @@ function _tocTitleForChapterSrc(chapterSrc) {
|
||||||
return entry ? entry.label : '';
|
return entry ? entry.label : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rough "how far through the book" position for a resolved range — there's no
|
||||||
|
// real page concept for reflowable EPUB text, so this is a coordinate of last
|
||||||
|
// resort: percent of total scrollable content height, good enough to roughly
|
||||||
|
// relocate a highlight/note later.
|
||||||
|
function _percentFromRange(range) {
|
||||||
|
const contentEl = $('reader-content');
|
||||||
|
if (!contentEl || !range || !contentEl.scrollHeight) return null;
|
||||||
|
const contentRect = contentEl.getBoundingClientRect();
|
||||||
|
const rangeRect = range.getBoundingClientRect();
|
||||||
|
if (!rangeRect.width && !rangeRect.height) return null;
|
||||||
|
const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop;
|
||||||
|
return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sortedAnnotations() {
|
||||||
|
const resolved = currentHighlights.map(h => {
|
||||||
|
let range;
|
||||||
|
try { range = resolveAnchorRange(h); } catch (e) { range = null; }
|
||||||
|
return {h, range};
|
||||||
|
});
|
||||||
|
resolved.sort((a, b) => {
|
||||||
|
if (!a.range || !b.range) return 0;
|
||||||
|
return a.range.compareBoundaryPoints(Range.START_TO_START, b.range);
|
||||||
|
});
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
function exportAnnotations() {
|
function exportAnnotations() {
|
||||||
if (!currentHighlights.length) {
|
if (!currentHighlights.length) {
|
||||||
customAlert('No highlights or notes in this book yet.');
|
customAlert('No highlights or notes in this book yet.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = [];
|
const resolved = _sortedAnnotations();
|
||||||
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 titleEl = $('reader-title');
|
||||||
const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0];
|
const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0];
|
||||||
const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, ''];
|
const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||||
|
|
||||||
let lastChapter = null;
|
let lastChapter = null;
|
||||||
for (const {h} of resolved) {
|
for (const {h, range} of resolved) {
|
||||||
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
|
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
|
||||||
if (chapterTitle !== lastChapter) {
|
if (chapterTitle !== lastChapter) {
|
||||||
lines.push(`── ${chapterTitle || 'Untitled section'} ──`);
|
lines.push(`── ${chapterTitle || 'Untitled section'} ──`);
|
||||||
lastChapter = chapterTitle;
|
lastChapter = chapterTitle;
|
||||||
}
|
}
|
||||||
|
const pct = _percentFromRange(range);
|
||||||
|
const coord = pct !== null ? ` (≈${Math.round(pct)}% im Buch)` : '';
|
||||||
if (h.type === 'note') {
|
if (h.type === 'note') {
|
||||||
lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`);
|
lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`);
|
||||||
if (h.note) lines.push(` ${h.note}`);
|
if (h.note) lines.push(` ${h.note}`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`);
|
lines.push(`[Highlight · ${h.color || 'yellow'}]${coord} "${(h.anchor?.quote || '').trim()}"`);
|
||||||
if (h.note) lines.push(` Note: ${h.note}`);
|
if (h.note) lines.push(` Note: ${h.note}`);
|
||||||
}
|
}
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
@ -5541,6 +5616,66 @@ function exportAnnotations() {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List of every highlight/note in the book, in reading order, each jumpable —
|
||||||
|
// the same pattern as the bookmarks sidebar, but for annotations. Unlike the
|
||||||
|
// margin panel (which only shows what's currently on-screen), this is a full
|
||||||
|
// overview, useful once a book has annotations scattered throughout.
|
||||||
|
function openAnnotationsSidebar() {
|
||||||
|
if (!currentHighlights.length) {
|
||||||
|
openSidebar('Highlights & Notes', '<p class="muted">No highlights or notes yet.</p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = _sortedAnnotations();
|
||||||
|
let html = '<ul style="list-style:none;padding:0;">';
|
||||||
|
for (const {h, range} of resolved) {
|
||||||
|
const pct = _percentFromRange(range);
|
||||||
|
const pctLabel = pct !== null ? `≈${Math.round(pct)}%` : '';
|
||||||
|
const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`;
|
||||||
|
const preview = h.note || h.anchor?.quote || '(no text)';
|
||||||
|
html += `<li class="bookmark-entry">
|
||||||
|
<button class="btn-link" data-jump-annotation="${escapeHtml(h.id)}" style="flex:1;text-align:left;">
|
||||||
|
<span class="muted" style="font-size:11px;">${escapeHtml(kind)} ${pctLabel}</span><br>
|
||||||
|
${escapeHtml(preview.slice(0, 80))}
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Delete">✕</button>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
openSidebar('Highlights & Notes', html);
|
||||||
|
|
||||||
|
const body = $('sidebar-body');
|
||||||
|
body.addEventListener('click', function _annClick(e) {
|
||||||
|
const jumpBtn = e.target.closest('[data-jump-annotation]');
|
||||||
|
const delBtn = e.target.closest('[data-delete-annotation]');
|
||||||
|
if (jumpBtn) {
|
||||||
|
body.removeEventListener('click', _annClick);
|
||||||
|
jumpToAnnotation(jumpBtn.dataset.jumpAnnotation);
|
||||||
|
}
|
||||||
|
if (delBtn) {
|
||||||
|
deleteHighlight(delBtn.dataset.deleteAnnotation);
|
||||||
|
openAnnotationsSidebar(); // re-render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToAnnotation(id) {
|
||||||
|
const h = currentHighlights.find(x => x.id === id);
|
||||||
|
if (!h) return;
|
||||||
|
closeSidebar();
|
||||||
|
_suppressScrollJumpDetect();
|
||||||
|
setTimeout(() => {
|
||||||
|
const contentEl = $('reader-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
let range;
|
||||||
|
try { range = resolveAnchorRange(h); } catch (e) { range = null; }
|
||||||
|
if (range) {
|
||||||
|
const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||||||
|
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Footnote popover
|
// Footnote popover
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v23';
|
const CACHE = 'diora-v25';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,6 @@
|
||||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
<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-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-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-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" 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>
|
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||||
|
|
@ -353,7 +352,11 @@
|
||||||
<aside id="reader-margin" class="reader-margin">
|
<aside id="reader-margin" class="reader-margin">
|
||||||
<div class="reader-margin-header">
|
<div class="reader-margin-header">
|
||||||
<span class="reader-margin-title">Notes</span>
|
<span class="reader-margin-title">Notes</span>
|
||||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
<span class="reader-margin-header-actions">
|
||||||
|
<button class="btn-icon" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text">✒</button>
|
||||||
|
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights & notes, jump to any of them">▦</button>
|
||||||
|
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="reader-margin-markers" class="reader-margin-markers"></div>
|
<div id="reader-margin-markers" class="reader-margin-markers"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue