Notizleiste: ruckelfrei in langen Büchern; sw v104
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:
parent
b8a68c63db
commit
fd8f20ba3a
3 changed files with 89 additions and 39 deletions
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
122
static/js/app.js
122
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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue