Notizleiste: ruckelfrei in langen Büchern; sw v104
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m19s

Mit offener Notizleiste kostete jeder Scroll-Schritt in Das Kapital (26.000
Blöcke, 8 Markierungen) 90–110 ms, jetzt rund 3 ms:
- resolveAnchorRange nimmt die zwischengespeicherte Blockliste (_epubBlocks)
  statt für jede Markierung alle Blöcke neu zu suchen.
- Die Position jeder Markierung im Buch wird gemerkt, bis sich das Layout
  ändert.
- Die Karten liegen auf einer Ebene in Buchkoordinaten; beim Scrollen wird
  nur diese verschoben. Gebaut und vermessen wird nur, wenn sich Markierungen
  oder Layout ändern, und dann alle Karten in einem Durchgang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-14 18:15:58 +02:00
parent b8a68c63db
commit fd8f20ba3a
3 changed files with 89 additions and 39 deletions

View file

@ -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; }

View file

@ -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();
}
});

View file

@ -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',