diff --git a/static/css/app.css b/static/css/app.css index cd1bc4e..5dcb5ac 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index 959acce..5ac24f8 100644 --- a/static/js/app.js +++ b/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 (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', ` - - - `); - 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) { diff --git a/static/js/sw.js b/static/js/sw.js index 979594c..5022ab8 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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',