diff --git a/static/css/app.css b/static/css/app.css index 5689138..f0eaa59 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1903,7 +1903,9 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) .reader-margin.open .reader-margin-header { display:flex; } .reader-margin-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted,#888); } .reader-margin-header-actions { display:flex; align-items:center; gap:6px; } -.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; } +.reader-margin-markers { position:relative; flex:1; overflow:hidden; cursor:crosshair; contain:layout paint; } +/* The cards in book coordinates; scrolling moves this layer (app.js _repositionMarginMarkers). */ +.margin-layer { position:absolute; top:0; left:0; right:0; height:0; will-change:transform; } .margin-note { position:absolute; left:8px; right:8px; cursor:pointer; } diff --git a/static/js/app.js b/static/js/app.js index 1dad5a3..d64eb01 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5688,7 +5688,7 @@ function resolveAnchorRange(h) { let range = null; try { - const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + const {blocks} = _epubBlocks(contentEl); const startBlock = blocks[h.anchor.startBlockIndex]; const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock; if (startBlock && endBlock) { @@ -6061,38 +6061,86 @@ function toggleAnnotationsMargin() { // margin — you can read notes there, not just see that one exists); a plain // highlight with no note is just a small color dot. Cards are stacked // top-to-bottom so overlapping anchors don't overlap visually. +// Where each highlight sits in the book's scroll coordinates. Finding a range +// and measuring it is the expensive part, and scrolling doesn't change it: +// kept until the layout does (content, width, height) or the anchor changes. +let _marginPosCache = null; +const _marginCardHeights = new Map(); // `${stripWidth}|${note}` (or 'dot') → px + +function _marginDocY(h, contentEl) { + const {blocks} = _epubBlocks(contentEl); + const key = `${contentEl.clientWidth}x${contentEl.scrollHeight}`; + if (!_marginPosCache || _marginPosCache.blocks !== blocks || _marginPosCache.key !== key) { + _marginPosCache = {blocks, key, byId: new Map()}; + } + const hit = _marginPosCache.byId.get(h.id); + if (hit && hit.anchor === h.anchor) return hit.y; + let y = null; + try { + const range = resolveAnchorRange(h); + const rect = range && range.getBoundingClientRect(); + if (rect && (rect.width || rect.height)) { + y = rect.top + rect.height / 2 - contentEl.getBoundingClientRect().top + contentEl.scrollTop; + } + } catch (e) {} + _marginPosCache.byId.set(h.id, {anchor: h.anchor, y}); + return y; +} + +// The cards live on one layer in the book's coordinates, built when the +// highlights or the layout change; scrolling only moves the layer (a transform, +// no layout). Rebuilding and measuring the cards on every scroll frame made a +// long book with the margin open stutter. +let _marginLayerKey = null; + function _repositionMarginMarkers() { const markersEl = $('reader-margin-markers'); if (!markersEl) return; + if (!readerAnnotationsMarginOpen || currentPdfDoc) { + markersEl.innerHTML = ''; + _marginLayerKey = null; + return; + } if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit - markersEl.innerHTML = ''; - if (!readerAnnotationsMarginOpen || currentPdfDoc) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + const {blocks} = _epubBlocks(contentEl); const areaRect = markersEl.getBoundingClientRect(); - // A generous pre-filter band: a note card can be considerably taller than - // its own anchor point, so a card whose anchor is somewhat outside the - // strictly-visible area can still be partially (or fully) on-screen once - // rendered. A tight band here excluded it before it ever got a chance to - // be measured — the exact bug reported ("tall notes stay hidden until - // scrolled much further than expected"). - const band = Math.max(areaRect.height, 400); + const width = Math.round(areaRect.width); + const key = `${width}x${contentEl.clientWidth}x${contentEl.scrollHeight}|` + + currentHighlights.map(h => `${h.id}:${h.color || ''}:${h.note || ''}`).join('|'); + let layer = markersEl.querySelector('.margin-layer'); + if (!layer || key !== _marginLayerKey || !_marginPosCache || _marginPosCache.blocks !== blocks) { + markersEl.innerHTML = ''; + layer = document.createElement('div'); + layer.className = 'margin-layer'; + markersEl.appendChild(layer); + _renderMarginLayer(layer, contentEl, width); + _marginLayerKey = key; + } + // Book coordinates → this strip's. + const shift = contentEl.getBoundingClientRect().top - contentEl.scrollTop - areaRect.top; + layer.style.transform = `translateY(${Math.round(shift)}px)`; +} + +// Renders each highlight/note at its text position, in book coordinates. +// Entries with note text become a readable "post-it" card (the whole point of +// the margin — you can read notes there, not just see that one exists); a +// plain highlight with no note is a small colour dot. Cards are stacked top to +// bottom so overlapping anchors don't overlap visually. +function _renderMarginLayer(layer, contentEl, width) { const entries = []; for (const h of currentHighlights) { - let range; - try { range = resolveAnchorRange(h); } catch (e) { continue; } - if (!range) continue; - const rect = range.getBoundingClientRect(); - if (!rect.width && !rect.height) continue; // detached/invalid - const y = rect.top + rect.height / 2 - areaRect.top; - if (y < -band || y > areaRect.height + band) continue; - entries.push({h, y}); + const y = _marginDocY(h, contentEl); + if (y !== null) entries.push({h, y}); } entries.sort((a, b) => a.y - b.y); - let nextTop = -Infinity; - const GAP = 4; - const rendered = []; - for (const {h, y} of entries) { + // All cards first, then their heights in one go (each measured right after + // adding it, the browser laid out the whole book once per card). Heights are + // kept per note text and strip width. + const cards = entries.map(({h, y}) => { const hasText = !!h.note; const el = document.createElement('div'); el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot'); @@ -6103,22 +6151,19 @@ function _repositionMarginMarkers() { el.dataset.color = h.color || 'yellow'; } if (hasText) el.textContent = h.note; + layer.appendChild(el); + return {el, y, hasText, key: hasText ? `${width}|${h.note}` : 'dot'}; + }); + if (_marginCardHeights.size > 500) _marginCardHeights.clear(); + const unmeasured = cards.filter(c => !_marginCardHeights.has(c.key)); + for (const c of unmeasured) _marginCardHeights.set(c.key, c.el.getBoundingClientRect().height || (c.hasText ? 20 : 12)); - const desiredTop = hasText ? y - 8 : y - 6; - const top = Math.max(desiredTop, nextTop); + let nextTop = -Infinity; + const GAP = 4; + for (const {el, y, hasText, key} of cards) { + const top = Math.max(hasText ? y - 8 : y - 6, nextTop); el.style.top = top + 'px'; - markersEl.appendChild(el); - - const measured = el.getBoundingClientRect().height || (hasText ? 20 : 12); - nextTop = top + measured + GAP; - rendered.push({el, top, measured}); - } - - // Now that actual card heights are known, do the real cull: drop anything - // that ended up entirely outside the visible band after stacking (e.g. - // pushed down by a very tall card above it). - for (const {el, top, measured} of rendered) { - if (top + measured < -40 || top > areaRect.height + 40) el.remove(); + nextTop = top + _marginCardHeights.get(key) + GAP; } } @@ -6246,7 +6291,8 @@ function editNoteInlineInMargin(h, pending) { let top = pending?.top || 0; if (h) { const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); - top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0; + // The card sits on the moving layer: where it shows in the strip right now. + top = existingCard ? existingCard.getBoundingClientRect().top - markersEl.getBoundingClientRect().top : 0; if (existingCard) existingCard.style.display = 'none'; } @@ -6261,6 +6307,7 @@ function editNoteInlineInMargin(h, pending) { function commit() { _commitNoteEdit(h, pending, textarea.value.trim()); editor.remove(); + _marginLayerKey = null; // the edited card was hidden: draw the layer anew _repositionMarginMarkers(); } @@ -6277,6 +6324,7 @@ function editNoteInlineInMargin(h, pending) { e.stopPropagation(); textarea.removeEventListener('blur', commit); // discard, don't save editor.remove(); + _marginLayerKey = null; _repositionMarginMarkers(); } }); diff --git a/static/js/sw.js b/static/js/sw.js index 467ebc3..cc0af47 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-v103'; +const CACHE = 'diora-v104'; const SHELL = [ '/static/css/app.css', '/static/js/app.js',