diora-web/static/js/app/72-highlights.js
mrwnslz 65a01569f9
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m32s
Test / e2e (push) Failing after 1m30s
PDF: markierbarer Text, Suche im ganzen Dokument, anklickbare Links mit Zurück (sw CACHE v165)
Über jeder gezeichneten Seite liegt die Textebene von pdf.js (auswählen, kopieren) und eine
Ebene mit den Links: Sprünge im Dokument gehen dorthin, ein Knopf führt zurück; Web-Adressen
öffnen außerhalb. Die Suche geht durch den Text aller Seiten (je Dokument einmal geholt, auch
für den Zuschnitt im Seitenmodus) statt nur durch die gerade gezeichneten, markiert den Treffer
genau und springt in beiden Modi hin. Tippen und Wischen auf Text blättern weiter, außer
nach einer Auswahl; Markierungen bleiben EPUBs vorbehalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:16:27 +01:00

426 lines
16 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// Highlights
// ---------------------------------------------------------------------------
async function loadHighlights(bookId) {
try {
const res = await fetch(`/books/${bookId}/highlights/`);
const {ct, iv} = await res.json();
if (ct) {
_saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} else {
currentHighlights = [];
}
applyHighlightsToContent();
} catch (e) {
// offline fallback
const cached = await _loadAnnotationsCache(bookId, 'highlights');
if (cached?.ct) {
try {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, cached.iv, cached.ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} catch { currentHighlights = []; }
} else {
currentHighlights = [];
}
applyHighlightsToContent();
}
}
async function saveHighlights() {
if (!currentBookId) return;
try {
const key = await getOrCreateEncKey();
const plain = new TextEncoder().encode(JSON.stringify(currentHighlights));
const {iv, ciphertext} = await encryptBytes(key, plain);
const body = JSON.stringify({ct: ciphertext, iv});
const url = `/books/${currentBookId}/highlights/`;
_lastHighlightBeacon = {url, body};
_saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
highlightsDirty = false;
} catch (e) {
_markPendingSync(currentBookId, 'highlights');
}
}
let _highlightSaveDebounce = null;
function debounceSaveHighlights() {
clearTimeout(_highlightSaveDebounce);
_highlightSaveDebounce = setTimeout(saveHighlights, 2000);
}
function applyHighlightsToContent() {
const contentEl = $('reader-content');
if (!contentEl || currentPdfDoc) return;
for (const h of currentHighlights) {
try { renderHighlight(h); } catch (e) {}
}
_repositionMarginMarkers();
}
// 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 null;
let range = null;
try {
const {blocks} = _epubBlocks(contentEl);
const startBlock = blocks[h.anchor.startBlockIndex];
const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock;
if (startBlock && endBlock) {
const start = _nodeAtCharOffset(startBlock, h.anchor.startChar);
const end = _nodeAtCharOffset(endBlock, h.anchor.endChar);
if (start && end) {
range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
}
}
} catch (e) {}
// Fallback: quote substring search within the chapter
if (!range && h.anchor.quote) {
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl;
const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const idx = node.textContent.indexOf(h.anchor.quote);
if (idx !== -1) {
range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + h.anchor.quote.length);
break;
}
}
}
return range;
}
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;
mark.className = 'epub-highlight';
mark.dataset.color = h.color || 'yellow';
range.surroundContents(mark);
} catch (e) {}
}
// Character-offset addressing within a block element (p, li, ...), rather
// than XPath-to-a-specific-text-node: XPath sibling indices shift whenever
// ANY highlight in the same block gets wrapped/unwrapped in a <mark> (that
// splits/merges text nodes), silently corrupting OTHER highlights' anchors
// in the same block. A block's overall textContent length/order is never
// touched by mark-wrapping, so a character offset into it stays valid no
// matter how many other highlights come and go around it.
function _charOffsetInBlock(block, node, nodeOffset) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let count = 0, n;
while ((n = walker.nextNode())) {
if (n === node) return count + nodeOffset;
count += n.textContent.length;
}
return count;
}
function _nodeAtCharOffset(block, targetOffset) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let count = 0, lastNode = null, n;
while ((n = walker.nextNode())) {
lastNode = n;
const len = n.textContent.length;
if (targetOffset <= count + len) {
return {node: n, offset: Math.max(0, targetOffset - count)};
}
count += len;
}
return lastNode ? {node: lastNode, offset: lastNode.textContent.length} : null;
}
function _blockAncestor(node) {
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return el ? el.closest(EPUB_BLOCK_SELECTOR) : null;
}
function buildEpubAnchor(range) {
const contentEl = $('reader-content');
const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? range.commonAncestorContainer.closest('[data-epub-src]')
: range.commonAncestorContainer.parentElement?.closest('[data-epub-src]');
const startBlock = _blockAncestor(range.startContainer);
const endBlock = _blockAncestor(range.endContainer) || startBlock;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
return {
type: 'epub',
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1,
startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0,
endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1,
endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0,
quote: range.toString().slice(0, 200),
};
}
// 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;
document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive));
}
// Note editing/creation happens in the margin column on desktop, but a note
// bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a
// 150px margin strip on a phone keyboard is unpleasant, and opening that
// column at all is exactly the friction we're trying to remove for mobile
// note-taking. Reviewing existing notes in depth is expected to stay a
// desktop activity; this only affects where the editor for a note appears.
function _isMobileReader() {
return window.innerWidth <= 600;
}
function handleReaderSelection(e) {
// Clicking an existing highlight always shows its tooltip, regardless of marker mode
const hlMark = e.target.closest('.epub-highlight');
if (hlMark) {
dismissHighlightPopover();
const id = hlMark.dataset.highlightId;
const h = currentHighlights.find(x => x.id === id);
showHighlightTooltip(hlMark, h);
return;
}
// Markings belong to books (an article has nowhere to keep them, 65-article.js), and to
// EPUBs only: a PDF's text layer is there to select and copy.
if (!markerModeActive || !currentBookId || currentPdfDoc) return;
// Don't hijack taps on real interactive content (footnote refs, external
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
// strips its href but the tag itself stays); those aren't actually
// clickable and must not block note-taking, so check for a surviving
// href/footnote-ref rather than just "is inside some <a>".
if (e.target.closest('button, [data-footnote-ref], a[href]')) return;
dismissHighlightPopover();
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.rangeCount) {
const range = sel.getRangeAt(0);
const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return;
if (range.toString().trim().length === 0) return;
showHighlightPopover(range);
return;
}
// A plain tap with nothing selected — in marker mode this means "drop a
// freeform note here" instead of doing nothing, so a note-only annotation
// never depends on finding the margin's precise click target (especially
// hard on a narrow phone screen — see createFreeformNote/_openNoteEditor).
if (typeof e.clientX === 'number' && typeof e.clientY === 'number') {
const range = _wordRangeAtPoint(e.clientX, e.clientY);
if (range) createFreeformNote(range);
}
}
function showHighlightPopover(range) {
const rect = range.getBoundingClientRect();
const popover = document.createElement('div');
popover.id = 'highlight-popover';
popover.className = 'highlight-popover';
popover.innerHTML = `
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Gelb">A</button>
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Grün">A</button>
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blau">A</button>
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Rot">A</button>
<button class="hl-note-btn" title="Notiz hinzufügen">✎</button>
`;
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();
popover.addEventListener('click', e => {
const colorBtn = e.target.closest('.hl-color-btn');
const noteBtn = e.target.closest('.hl-note-btn');
if (colorBtn) {
createHighlight(colorBtn.dataset.hlColor, savedRange);
} else if (noteBtn) {
createHighlightWithNote(savedRange);
}
});
}
function showHighlightTooltip(markEl, h) {
const rect = markEl.getBoundingClientRect();
const popover = document.createElement('div');
popover.id = 'highlight-popover';
popover.className = 'highlight-popover';
popover.style.flexDirection = 'column';
popover.style.maxWidth = '220px';
const noteText = h?.note ? escapeHtml(h.note) : '<span class="muted">Keine Notiz</span>';
popover.innerHTML = `
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
<div style="display:flex;gap:6px;">
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Notiz bearbeiten</button>
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Löschen</button>
</div>
`;
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]');
if (editBtn && h) {
dismissHighlightPopover();
_openNoteEditor(h);
}
if (delBtn && h) {
dismissHighlightPopover();
deleteHighlight(h.id);
}
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', dismissHighlightPopover, {once: true});
}, 0);
}
function createHighlight(color, range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
type: 'highlight',
anchor,
color,
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
_repositionMarginMarkers();
debounceSaveHighlights();
}
function createHighlightWithNote(range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
type: 'highlight',
anchor,
color: 'yellow',
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
_repositionMarginMarkers();
_openNoteEditor(h);
}
// Every "add/edit a note" entry point routes here — notes are always edited
// right where they conceptually live (the margin), never in the separate
// right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is
// too cramped to type into and a full-width bottom sheet takes over instead.
// `pending` is only set for a not-yet-created freeform note (see
// createFreeformNote); h is null in that case until the user actually saves
// text, so an empty tap doesn't leave a permanent ghost entry behind.
function _openNoteEditor(h, pending) {
if (_isMobileReader()) {
showNoteBottomSheet(h, pending);
} else {
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
editNoteInlineInMargin(h, pending);
}
}
function deleteHighlight(id) {
currentHighlights = currentHighlights.filter(h => h.id !== id);
highlightsDirty = true;
// Re-apply all highlights after removing the deleted one
const contentEl = $('reader-content');
if (contentEl && !currentPdfDoc) {
// Snapshot restore not available mid-session, so remove the mark manually
const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`);
if (mark) {
const parent = mark.parentNode;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
}
}
_repositionMarginMarkers();
debounceSaveHighlights();
}
function dismissHighlightPopover() {
if (currentHighlightPopover) {
currentHighlightPopover.remove();
currentHighlightPopover = null;
}
}