'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 (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; } if (!markerModeActive) 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 // 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 ". 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 = ` `; 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) : 'Keine Notiz'; popover.innerHTML = `
${noteText}
`; 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; } }