diora-web/static/js/app/72-highlights.js

427 lines
16 KiB
JavaScript
Raw Permalink Normal View History

'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;
}
}