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;
|
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;
|
||||||
|
|
|
||||||
115
static/js/app.js
115
static/js/app.js
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue