Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v26)
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:
parent
3f40a4078a
commit
7bfce034ba
3 changed files with 67 additions and 57 deletions
|
|
@ -1604,6 +1604,11 @@ body.dnd-mode .timer-display {
|
|||
z-index: 200;
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
|
|
|
|||
117
static/js/app.js
117
static/js/app.js
|
|
@ -5017,22 +5017,26 @@ function resolveAnchorRange(h) {
|
|||
const contentEl = $('reader-content');
|
||||
if (!contentEl || !h.anchor) return null;
|
||||
|
||||
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|
||||
|| contentEl;
|
||||
|
||||
let range = null;
|
||||
try {
|
||||
const startNode = xpathToNode(h.anchor.startXpath, chapterEl);
|
||||
const endNode = xpathToNode(h.anchor.endXpath, chapterEl);
|
||||
if (startNode && endNode) {
|
||||
range = document.createRange();
|
||||
range.setStart(startNode, h.anchor.startOffset);
|
||||
range.setEnd(endNode, h.anchor.endOffset);
|
||||
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||||
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
|
||||
// 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())) {
|
||||
|
|
@ -5067,30 +5071,40 @@ function renderHighlight(h) {
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
function xpathToNode(xpath, root) {
|
||||
if (!xpath) return null;
|
||||
const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
||||
return result.singleNodeValue;
|
||||
// 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 getXPathForNode(node, root) {
|
||||
const parts = [];
|
||||
let current = node;
|
||||
while (current && current !== root) {
|
||||
const parent = current.parentNode;
|
||||
if (!parent) break;
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
|
||||
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}]`);
|
||||
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)};
|
||||
}
|
||||
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) {
|
||||
|
|
@ -5098,15 +5112,18 @@ function buildEpubAnchor(range) {
|
|||
const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.commonAncestorContainer.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 {
|
||||
type: 'epub',
|
||||
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
|
||||
startXpath: getXPathForNode(range.startContainer, root),
|
||||
startOffset: range.startOffset,
|
||||
endXpath: getXPathForNode(range.endContainer, root),
|
||||
endOffset: range.endOffset,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -5227,7 +5244,7 @@ function showHighlightTooltip(markEl, h) {
|
|||
const delBtn = ev.target.closest('[data-hl-delete]');
|
||||
if (editBtn && h) {
|
||||
dismissHighlightPopover();
|
||||
openNoteEditor(h);
|
||||
_openMarginAndEditNote(h);
|
||||
}
|
||||
if (delBtn && h) {
|
||||
dismissHighlightPopover();
|
||||
|
|
@ -5276,28 +5293,16 @@ function createHighlightWithNote(range) {
|
|||
dismissHighlightPopover();
|
||||
renderHighlight(h);
|
||||
_repositionMarginMarkers();
|
||||
openNoteEditor(h);
|
||||
_openMarginAndEditNote(h);
|
||||
}
|
||||
|
||||
// Freeform note, anchored via the margin panel rather than a text selection —
|
||||
// see createFreeformNote() below. Reused for editing notes on existing highlights too.
|
||||
function openNoteEditor(h) {
|
||||
openSidebar('Edit note', `
|
||||
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea>
|
||||
<button class="btn" style="margin-top:8px;" data-save-note="${escapeHtml(h.id)}">Save note</button>
|
||||
`);
|
||||
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();
|
||||
});
|
||||
// Every "add/edit a note" entry point routes here — notes are only ever
|
||||
// written where they live, in the margin, never in the separate right-hand
|
||||
// sidebar (that used to mean a jarring jump away from the text you're
|
||||
// annotating).
|
||||
function _openMarginAndEditNote(h) {
|
||||
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
|
||||
editNoteInlineInMargin(h);
|
||||
}
|
||||
|
||||
function deleteHighlight(id) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue