diora-web/static/js/app/74-margin.js
mrwnslz a7d4d92f9e
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m19s
Test / e2e (push) Successful in 1m52s
Reader: offene Notizleiste schiebt den Text nicht mehr aus der Mitte; sw v107
Die Leiste nimmt ihre Breite links weg; dieselbe Breite kommt rechts als
Abstand dazu, soweit Platz ist (die Spalte wird dafür nie schmaler). Beides
gleitet gleichzeitig, der Text bleibt stehen. Neu berechnet beim Öffnen und
Schließen der Leiste, bei Fenstergröße und Zeilenlänge/Schrift.

e2e: Test dafür (breit: Spalte bleibt, schmal: weicht aus, bleibt gleich
breit, liegt nicht unter der Leiste); Service Worker in den Tests aus (sein
Neuladen störte mitten im Test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:43:03 +02:00

435 lines
18 KiB
JavaScript

'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);
_centerReaderColumn();
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 = '';
}
}
// The margin takes its width from the left, which pushes the text column off
// the middle of the window. Where there's room, the same width added on the
// right puts it back; where there isn't, as much as fits (the column never
// gets narrower for it). Both widths change together, so the text stays put.
function _centerReaderColumn() {
const contentEl = $('reader-content');
const marginEl = $('reader-margin');
const rowEl = $('reader-body-row');
if (!contentEl) return;
if (!readerAnnotationsMarginOpen || !marginEl || !rowEl || currentPdfDoc) {
contentEl.style.paddingRight = '';
return;
}
const margin = parseFloat(getComputedStyle(marginEl).getPropertyValue('--reader-margin-w')) || 0;
const side = parseFloat(getComputedStyle(contentEl).paddingLeft) || 0;
// The column's width: --reader-max-width, measured in the reader's font.
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;visibility:hidden;height:0;width:var(--reader-max-width,65ch)';
contentEl.appendChild(probe);
const column = probe.getBoundingClientRect().width;
probe.remove();
const room = rowEl.clientWidth - margin - 2 * side - column;
contentEl.style.paddingRight = (side + Math.max(0, Math.min(margin, room))) + 'px';
}
window.addEventListener('resize', () => { if (readerAnnotationsMarginOpen) _centerReaderColumn(); });
// 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;
}
}