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';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 <mark> (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;
|
|
|
|
|
}
|
|
|
|
|
|
Nachrichten und Blogs im Buch-Reader, Doppelklick für Vollbild, Hörbücher wie die Bücher-Bibliothek
65-article.js (DioraArticle): Artikel öffnen im selben Reader wie Bücher – Schrift, Farben,
Seiten/Doppelseite mit Fußnoten am Seitenfuß, Suche, Vorlesen, Vollbild. Lesezeichen,
Markierungen und Notizspalte bleiben Büchern vorbehalten. Merken, Projekt und Original
stehen in der Kopfzeile (Apps: im Lesemenü). feeds.js gibt sein eigenes Overlay auf;
Fortschritt wie bisher (bis zum unteren Bildschirmrand gelesen, ab 90 % gelesen).
Einstellungen der Artikel gelten für alle Artikel zusammen.
62-reader.js: Doppelklick (Maus) auf den Text schaltet Vollbild an/aus, die Leisten gehen mit.
audiobooks.js: die Hörbücher sind gebaut wie die Bücher – Kopf mit Suche, Sortierung und +,
Chips Angefangen/Ungehört/Gehört und Ordner, „Weiterhören“ wie „Weiterlesen“, Kacheln aus
denselben Bausteinen (53-library.js gibt seine Styles auch an #audiobook-shelf), Cover quadratisch.
63-pages.js: bei nur einem Kapitel zeigt die Fußzeile nur die Seiten.
sw CACHE v158
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:15:00 +01:00
|
|
|
// Markings belong to books (an article has nowhere to keep them, 65-article.js).
|
|
|
|
|
if (!markerModeActive || !currentBookId) return;
|
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
|
|
|
// Don't hijack taps on real interactive content (footnote refs, external
|
|
|
|
|
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
|
|
|
|
|
// <a id="..."> 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 <a>".
|
|
|
|
|
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 = `
|
|
|
|
|
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Gelb">A</button>
|
|
|
|
|
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Grün">A</button>
|
|
|
|
|
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blau">A</button>
|
|
|
|
|
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Rot">A</button>
|
|
|
|
|
<button class="hl-note-btn" title="Notiz hinzufügen">✎</button>
|
|
|
|
|
`;
|
|
|
|
|
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) : '<span class="muted">Keine Notiz</span>';
|
|
|
|
|
popover.innerHTML = `
|
|
|
|
|
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
|
|
|
|
|
<div style="display:flex;gap:6px;">
|
|
|
|
|
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Notiz bearbeiten</button>
|
|
|
|
|
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Löschen</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|