Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne
Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei
'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der
beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale
Funktionen und let/const bleiben global, die App-Schichten (native.js,
native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen
die Dateien; diora-android build_web nimmt static/js rekursiv.
Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im
Reader, Hell/Dunkel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:43 +02:00
|
|
|
'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);
|
2026-10-14 19:43:03 +02:00
|
|
|
_centerReaderColumn();
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne
Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei
'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der
beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale
Funktionen und let/const bleiben global, die App-Schichten (native.js,
native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen
die Dateien; diora-android build_web nimmt static/js rekursiv.
Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im
Reader, Hell/Dunkel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:43 +02:00
|
|
|
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 = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:43:03 +02:00
|
|
|
// 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(); });
|
|
|
|
|
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne
Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei
'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der
beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale
Funktionen und let/const bleiben global, die App-Schichten (native.js,
native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen
die Dateien; diora-android build_web nimmt static/js rekursiv.
Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im
Reader, Hell/Dunkel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:43 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|