'use strict'; // --------------------------------------------------------------------------- // Margin panel — a "page margin" strip left of the reader content. Shows small // markers next to the on-screen highlights/notes, and lets you click empty // space to anchor a freeform note at that line's height (EPUB only). // --------------------------------------------------------------------------- let readerAnnotationsMarginOpen = false; function toggleAnnotationsMargin() { const el = $('reader-margin'); const markersEl = $('reader-margin-markers'); if (!el || !markersEl || currentPdfDoc) return; readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen; el.classList.toggle('open', readerAnnotationsMarginOpen); if (readerAnnotationsMarginOpen) { // Reposition once now (pre-transition) and once more after the width // transition settles, since content reflow can shift line heights. _repositionMarginMarkers(); el.addEventListener('transitionend', _repositionMarginMarkers, {once: true}); } else { markersEl.innerHTML = ''; } } // 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. // Where each highlight sits in the book's scroll coordinates. Finding a range // and measuring it is the expensive part, and scrolling doesn't change it: // kept until the layout does (content, width, height) or the anchor changes. let _marginPosCache = null; const _marginCardHeights = new Map(); // `${stripWidth}|${note}` (or 'dot') → px function _marginDocY(h, contentEl) { const {blocks} = _epubBlocks(contentEl); const key = `${contentEl.clientWidth}x${contentEl.scrollHeight}`; if (!_marginPosCache || _marginPosCache.blocks !== blocks || _marginPosCache.key !== key) { _marginPosCache = {blocks, key, byId: new Map()}; } const hit = _marginPosCache.byId.get(h.id); if (hit && hit.anchor === h.anchor) return hit.y; let y = null; try { const range = resolveAnchorRange(h); const rect = range && range.getBoundingClientRect(); if (rect && (rect.width || rect.height)) { y = rect.top + rect.height / 2 - contentEl.getBoundingClientRect().top + contentEl.scrollTop; } } catch (e) {} _marginPosCache.byId.set(h.id, {anchor: h.anchor, y}); return y; } // The cards live on one layer in the book's coordinates, built when the // highlights or the layout change; scrolling only moves the layer (a transform, // no layout). Rebuilding and measuring the cards on every scroll frame made a // long book with the margin open stutter. let _marginLayerKey = null; function _repositionMarginMarkers() { const markersEl = $('reader-margin-markers'); if (!markersEl) return; if (!readerAnnotationsMarginOpen || currentPdfDoc) { markersEl.innerHTML = ''; _marginLayerKey = null; return; } if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit const contentEl = $('reader-content'); if (!contentEl) return; const {blocks} = _epubBlocks(contentEl); const areaRect = markersEl.getBoundingClientRect(); const width = Math.round(areaRect.width); const key = `${width}x${contentEl.clientWidth}x${contentEl.scrollHeight}|` + currentHighlights.map(h => `${h.id}:${h.color || ''}:${h.note || ''}`).join('|'); let layer = markersEl.querySelector('.margin-layer'); if (!layer || key !== _marginLayerKey || !_marginPosCache || _marginPosCache.blocks !== blocks) { markersEl.innerHTML = ''; layer = document.createElement('div'); layer.className = 'margin-layer'; markersEl.appendChild(layer); _renderMarginLayer(layer, contentEl, width); _marginLayerKey = key; } // Book coordinates → this strip's. const shift = contentEl.getBoundingClientRect().top - contentEl.scrollTop - areaRect.top; layer.style.transform = `translateY(${Math.round(shift)}px)`; } // Renders each highlight/note at its text position, in book coordinates. // 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 a small colour dot. Cards are stacked top to // bottom so overlapping anchors don't overlap visually. function _renderMarginLayer(layer, contentEl, width) { const entries = []; for (const h of currentHighlights) { const y = _marginDocY(h, contentEl); if (y !== null) entries.push({h, y}); } entries.sort((a, b) => a.y - b.y); // All cards first, then their heights in one go (each measured right after // adding it, the browser laid out the whole book once per card). Heights are // kept per note text and strip width. const cards = entries.map(({h, y}) => { 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; layer.appendChild(el); return {el, y, hasText, key: hasText ? `${width}|${h.note}` : 'dot'}; }); if (_marginCardHeights.size > 500) _marginCardHeights.clear(); const unmeasured = cards.filter(c => !_marginCardHeights.has(c.key)); for (const c of unmeasured) _marginCardHeights.set(c.key, c.el.getBoundingClientRect().height || (c.hasText ? 20 : 12)); let nextTop = -Infinity; const GAP = 4; for (const {el, y, hasText, key} of cards) { const top = Math.max(hasText ? y - 8 : y - 6, nextTop); el.style.top = top + 'px'; nextTop = top + _marginCardHeights.get(key) + GAP; } } function handleMarginClick(e) { const markerBtn = e.target.closest('.margin-note'); if (markerBtn) { const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); if (h) _openNoteEditor(h); return; } // Empty space in the markers area → place a new freeform note anchored to // the first word of whatever line sits at this click height. if (!readerAnnotationsMarginOpen || currentPdfDoc) return; const markersEl = $('reader-margin-markers'); 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'); if (!contentEl) return; const x = contentEl.getBoundingClientRect().left + 4; const range = _wordRangeAtPoint(x, e.clientY); if (range) createFreeformNote(range); } // Finds the word starting at (or just after) the given viewport point, by // probing near the left edge of the reader content at that height — since // text is left-aligned, that lands at (or very near) the start of the line. function _wordRangeAtPoint(x, y) { let caret = null; if (document.caretRangeFromPoint) { caret = document.caretRangeFromPoint(x, y); } else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (pos) { caret = document.createRange(); caret.setStart(pos.offsetNode, pos.offset); } } if (!caret) return null; const contentEl = $('reader-content'); if (!contentEl || !contentEl.contains(caret.startContainer)) return null; let node = caret.startContainer; let offset = caret.startOffset; if (node.nodeType !== Node.TEXT_NODE) { const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); walker.currentNode = node; node = walker.nextNode(); if (!node) return null; offset = 0; } const text = node.textContent; let start = offset; while (start < text.length && /\s/.test(text[start])) start++; if (start >= text.length) return null; let wordStart = start; while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; let wordEnd = start; while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; if (wordEnd <= wordStart) return null; const range = document.createRange(); range.setStart(node, wordStart); range.setEnd(node, wordEnd); return range; } function createFreeformNote(range) { const anchor = buildEpubAnchor(range); // Where the note's card/dot would sit in the margin, computed from the live // range right now (before it goes stale) — used to position the editor, // whether or not the margin is actually open. let top = 0; const markersEl = $('reader-margin-markers'); if (markersEl) { const areaRect = markersEl.getBoundingClientRect(); const rangeRect = range.getBoundingClientRect(); top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8); } // The highlight object itself isn't created yet — see _openNoteEditor's // `pending` param — so a tap that's immediately cancelled or left empty // never leaves a ghost entry in currentHighlights. _openNoteEditor(null, {anchor, top}); } // Commits an edit for both note-editor UIs (inline margin + mobile bottom // sheet): updates an existing note in place, or — only if there's actual // text — creates and persists a not-yet-existing freeform note. An empty // tap/cancel on a pending note must never leave a permanent ghost entry. function _commitNoteEdit(h, pending, text) { if (h) { if (h.note !== text) { h.note = text; highlightsDirty = true; debounceSaveHighlights(); } return h; } if (!text) return null; const newH = { id: crypto.randomUUID(), type: 'note', anchor: pending.anchor, color: null, note: text, createdAt: new Date().toISOString(), }; currentHighlights.push(newH); highlightsDirty = true; debounceSaveHighlights(); return newH; } // 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. // `h` is null for a not-yet-created freeform note (see createFreeformNote); // `pending` then carries its anchor + intended margin position instead. function editNoteInlineInMargin(h, pending) { const markersEl = $('reader-margin-markers'); if (!markersEl) return; markersEl.querySelector('.margin-note-editor')?.remove(); let top = pending?.top || 0; if (h) { const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); // The card sits on the moving layer: where it shows in the strip right now. top = existingCard ? existingCard.getBoundingClientRect().top - markersEl.getBoundingClientRect().top : 0; if (existingCard) existingCard.style.display = 'none'; } const editor = document.createElement('div'); editor.className = 'margin-note-editor'; editor.style.top = top + 'px'; editor.innerHTML = ''; markersEl.appendChild(editor); const textarea = editor.querySelector('textarea'); textarea.value = h?.note || ''; function commit() { _commitNoteEdit(h, pending, textarea.value.trim()); editor.remove(); _marginLayerKey = null; // the edited card was hidden: draw the layer anew _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(); _marginLayerKey = null; _repositionMarginMarkers(); } }); textarea.focus(); textarea.select(); } // Mobile alternative to editNoteInlineInMargin — a full-width sheet instead // of squeezing a textarea into the 150px margin column (see _openNoteEditor). let currentNoteBottomSheet = null; function dismissNoteBottomSheet() { currentNoteBottomSheet?.remove(); currentNoteBottomSheet = null; } function showNoteBottomSheet(h, pending) { dismissNoteBottomSheet(); const sheet = document.createElement('div'); sheet.className = 'note-bottom-sheet'; sheet.innerHTML = `
`; document.body.appendChild(sheet); currentNoteBottomSheet = sheet; const textarea = sheet.querySelector('.note-bottom-sheet-textarea'); textarea.value = h?.note || ''; function commit() { _commitNoteEdit(h, pending, textarea.value.trim()); dismissNoteBottomSheet(); if (readerAnnotationsMarginOpen) _repositionMarginMarkers(); } function cancel() { dismissNoteBottomSheet(); } sheet.querySelector('[data-nbs-save]').addEventListener('click', commit); sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel); sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel); textarea.addEventListener('keydown', e => { if (e.key === 'Escape') { e.stopPropagation(); cancel(); } }); requestAnimationFrame(() => { sheet.classList.add('open'); textarea.focus(); }); } // Same plain-text export as exportAnnotations(), but callable straight from the // book list (no open reader, so no live DOM/TOC to resolve percent position or // chapter titles against — falls back to the raw chapterSrc path and orders by // the anchor's block index, which is already a global reading-order index). async function downloadBookAnnotationsFromList(bookId, btnEl) { if (btnEl) btnEl.disabled = true; try { const res = await fetch(`/books/${bookId}/highlights/`); const {ct, iv} = await res.json(); if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, iv, ct); const highlights = JSON.parse(new TextDecoder().decode(plain)); if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } highlights.sort((a, b) => { const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0; if (ab !== bb) return ab - bb; return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0); }); const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`; const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; let lastChapter = null; for (const h of highlights) { const chapterTitle = h.anchor?.chapterSrc || ''; if (chapterTitle !== lastChapter) { lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); lastChapter = chapterTitle; } if (h.type === 'note') { lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` ${h.note}`); } else { lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` Notiz: ${h.note}`); } lines.push(''); } const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { await customAlert('Export fehlgeschlagen: ' + e.message); } finally { if (btnEl) btnEl.disabled = false; } }