diora-web/static/js/app/74-margin.js

409 lines
16 KiB
JavaScript
Raw Normal View History

'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 = '<textarea class="margin-note-textarea"></textarea>';
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 = `
<div class="note-bottom-sheet-backdrop"></div>
<div class="note-bottom-sheet-panel">
<textarea class="note-bottom-sheet-textarea" placeholder="Notiz…"></textarea>
<div class="note-bottom-sheet-actions">
<button class="btn btn-sm" data-nbs-cancel>Abbrechen</button>
<button class="btn btn-sm btn-primary" data-nbs-save>Speichern</button>
</div>
</div>
`;
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;
}
}