Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v26)
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 16s

Vier vom Nutzer gemeldete Bugs behoben:

- Markierter Text war kaum lesbar: der globale weiße Text-Outline (fürs
  Hintergrundbild-Feature gedacht) kollidierte mit der Highlight-Farbe.
  Reader sitzt immer auf eigenem, opakem Hintergrund — Outline dort komplett
  deaktiviert.
- "Notiz zu Markierung hinzufügen" öffnete weiterhin die rechte Sidebar statt
  den Rand. Jeder Notiz-Eintragspunkt (Highlight+Notiz aus der Textauswahl,
  Edit-Note-Klick auf eine Markierung im Fließtext) läuft jetzt über
  _openMarginAndEditNote() — öffnet bei Bedarf den Rand und bearbeitet dort
  inline. openNoteEditor()/rechte Sidebar für Notizen komplett entfernt.
- Kernursache für "Notiz verschwindet, Markierung heftet sich an falsche
  Notiz": Anker basierten auf XPath mit Sibling-Index unter den Textknoten
  eines Blocks — jede weitere Markierung im selben Block (surroundContents
  spaltet Textknoten) verschob diese Indizes und korrumpierte lautlos ALLE
  anderen Anker im selben Block. Anker adressieren jetzt stattdessen
  Block-Index + Zeichen-Offset innerhalb des Blocks (wie das bestehende
  Lesefortschritt-Ankersystem) — Wrapping/Unwrapping durch andere
  Markierungen ändert weder Blockreihenfolge noch Zeichenanzahl, ist also
  erschütterungsfrei für andere Anker im selben Block.
- Löschen einer Markierung riss zuvor aus demselben Grund weiter unten
  liegende Notizen optisch mit raus (Daten blieben in der DB) — mit dem
  neuen Ankersystem behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 13:28:19 +02:00
parent 3f40a4078a
commit 7bfce034ba
3 changed files with 67 additions and 57 deletions

View file

@ -1604,6 +1604,11 @@ body.dnd-mode .timer-display {
z-index: 200; z-index: 200;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* The global white text-outline (for readability over a custom background
image elsewhere in the app) is never needed here the reader always
sits on its own opaque background and it muddies highlighted text
badly when combined with a colored highlight background. */
text-shadow: none;
} }
.reader-header { .reader-header {
display: flex; display: flex;

View file

@ -5017,22 +5017,26 @@ function resolveAnchorRange(h) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl || !h.anchor) return null; if (!contentEl || !h.anchor) return null;
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl;
let range = null; let range = null;
try { try {
const startNode = xpathToNode(h.anchor.startXpath, chapterEl); const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const endNode = xpathToNode(h.anchor.endXpath, chapterEl); const startBlock = blocks[h.anchor.startBlockIndex];
if (startNode && endNode) { 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 = document.createRange();
range.setStart(startNode, h.anchor.startOffset); range.setStart(start.node, start.offset);
range.setEnd(endNode, h.anchor.endOffset); range.setEnd(end.node, end.offset);
}
} }
} catch (e) {} } catch (e) {}
// Fallback: quote substring search // Fallback: quote substring search within the chapter
if (!range && h.anchor.quote) { 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); const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT);
let node; let node;
while ((node = walker.nextNode())) { while ((node = walker.nextNode())) {
@ -5067,30 +5071,40 @@ function renderHighlight(h) {
} catch (e) {} } catch (e) {}
} }
function xpathToNode(xpath, root) { // Character-offset addressing within a block element (p, li, ...), rather
if (!xpath) return null; // than XPath-to-a-specific-text-node: XPath sibling indices shift whenever
const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); // ANY highlight in the same block gets wrapped/unwrapped in a <mark> (that
return result.singleNodeValue; // 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 getXPathForNode(node, root) { function _nodeAtCharOffset(block, targetOffset) {
const parts = []; const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let current = node; let count = 0, lastNode = null, n;
while (current && current !== root) { while ((n = walker.nextNode())) {
const parent = current.parentNode; lastNode = n;
if (!parent) break; const len = n.textContent.length;
if (current.nodeType === Node.TEXT_NODE) { if (targetOffset <= count + len) {
const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); return {node: n, offset: Math.max(0, targetOffset - count)};
const idx = siblings.indexOf(current);
parts.unshift(`text()[${idx + 1}]`);
} else {
const siblings = Array.from(parent.children).filter(n => n.tagName === current.tagName);
const idx = siblings.indexOf(current);
parts.unshift(`${current.tagName.toLowerCase()}[${idx + 1}]`);
} }
current = parent; count += len;
} }
return parts.join('/'); 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) { function buildEpubAnchor(range) {
@ -5098,15 +5112,18 @@ function buildEpubAnchor(range) {
const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? range.commonAncestorContainer.closest('[data-epub-src]') ? range.commonAncestorContainer.closest('[data-epub-src]')
: range.commonAncestorContainer.parentElement?.closest('[data-epub-src]'); : range.commonAncestorContainer.parentElement?.closest('[data-epub-src]');
const root = chapterEl || contentEl;
const startBlock = _blockAncestor(range.startContainer);
const endBlock = _blockAncestor(range.endContainer) || startBlock;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
return { return {
type: 'epub', type: 'epub',
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '', chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
startXpath: getXPathForNode(range.startContainer, root), startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1,
startOffset: range.startOffset, startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0,
endXpath: getXPathForNode(range.endContainer, root), endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1,
endOffset: range.endOffset, endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0,
quote: range.toString().slice(0, 200), quote: range.toString().slice(0, 200),
}; };
} }
@ -5227,7 +5244,7 @@ function showHighlightTooltip(markEl, h) {
const delBtn = ev.target.closest('[data-hl-delete]'); const delBtn = ev.target.closest('[data-hl-delete]');
if (editBtn && h) { if (editBtn && h) {
dismissHighlightPopover(); dismissHighlightPopover();
openNoteEditor(h); _openMarginAndEditNote(h);
} }
if (delBtn && h) { if (delBtn && h) {
dismissHighlightPopover(); dismissHighlightPopover();
@ -5276,28 +5293,16 @@ function createHighlightWithNote(range) {
dismissHighlightPopover(); dismissHighlightPopover();
renderHighlight(h); renderHighlight(h);
_repositionMarginMarkers(); _repositionMarginMarkers();
openNoteEditor(h); _openMarginAndEditNote(h);
} }
// Freeform note, anchored via the margin panel rather than a text selection — // Every "add/edit a note" entry point routes here — notes are only ever
// see createFreeformNote() below. Reused for editing notes on existing highlights too. // written where they live, in the margin, never in the separate right-hand
function openNoteEditor(h) { // sidebar (that used to mean a jarring jump away from the text you're
openSidebar('Edit note', ` // annotating).
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea> function _openMarginAndEditNote(h) {
<button class="btn" style="margin-top:8px;" data-save-note="${escapeHtml(h.id)}">Save note</button> if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
`); editNoteInlineInMargin(h);
const body = $('sidebar-body');
body.addEventListener('click', function _noteClick(e) {
const btn = e.target.closest('[data-save-note]');
if (!btn) return;
body.removeEventListener('click', _noteClick);
const text = (body.querySelector('#hl-note-input')?.value || '').trim();
h.note = text;
highlightsDirty = true;
_repositionMarginMarkers();
debounceSaveHighlights();
closeSidebar();
});
} }
function deleteHighlight(id) { function deleteHighlight(id) {

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v25'; const CACHE = 'diora-v26';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',