fix(assistant): #91 question positionnee au bord haut de la fenetre (padding dynamique, overflow-anchor, passe de correction)
This commit is contained in:
+55
-13
@@ -890,12 +890,20 @@ class BooksLM {
|
||||
this._activatePath(path, kind);
|
||||
});
|
||||
|
||||
// A manual scroll releases the top-pinning of the latest question.
|
||||
// A manual scroll (wheel, touch or scrollbar drag) releases the
|
||||
// top-pinning of the latest question.
|
||||
const messagesEl = panel.querySelector('.bookslm-messages');
|
||||
if (messagesEl) {
|
||||
const unpin = () => { this._pinnedTurn = false; };
|
||||
const unpin = () => {
|
||||
if (!this._pinnedTurn) return;
|
||||
this._pinnedTurn = false;
|
||||
// The anchor padding is only there to make the post reachable while
|
||||
// it is pinned — drop it so the reader gets no blank tail.
|
||||
messagesEl.style.paddingBottom = '';
|
||||
};
|
||||
messagesEl.addEventListener('wheel', unpin, { passive: true });
|
||||
messagesEl.addEventListener('touchmove', unpin, { passive: true });
|
||||
messagesEl.addEventListener('mousedown', unpin, { passive: true });
|
||||
}
|
||||
|
||||
// Close the session / command menus when clicking elsewhere in the panel.
|
||||
@@ -1794,6 +1802,9 @@ class BooksLM {
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
container.innerHTML = '';
|
||||
// NOTE: the anchor's bottom padding must survive the re-render. Clearing
|
||||
// it here (outside the synchronous scroll correction) let the browser
|
||||
// paint one frame with the question pushed back down the window.
|
||||
|
||||
// Notion-style thread: chronological order, the user post anchored at the
|
||||
// top of the visible area (never scroll-to-bottom — the answer streams
|
||||
@@ -1803,17 +1814,29 @@ class BooksLM {
|
||||
container.appendChild(this._chatBubble(msg));
|
||||
}
|
||||
|
||||
if (opts.anchor) this._anchorLatestUser({ instant: !!opts.instant });
|
||||
else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true });
|
||||
else container.scrollTop = prevTop;
|
||||
// While the pin is active (question submitted, released by a manual
|
||||
// scroll) every re-render — streaming frames *and* the final one — must
|
||||
// put the question back at the top, otherwise the answer's own growth
|
||||
// would push it back down the window.
|
||||
if (opts.anchor || this._pinnedTurn) {
|
||||
this._anchorLatestUser({ instant: opts.anchor ? !!opts.instant : true });
|
||||
} else {
|
||||
// Unpinned: drop the anchor padding so no blank gap lingers.
|
||||
container.style.paddingBottom = '';
|
||||
container.scrollTop = prevTop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the messages window so the most recent user post sits at the very
|
||||
* top of the viewport (its `scroll-margin-top` keeps a breathing gap),
|
||||
* reproducing Notion-AI's conversation anchoring. The pin stays active
|
||||
* while the answer streams, so the question never leaves the top — until
|
||||
* the user scrolls manually (see the wheel/touch listeners in `_render()`).
|
||||
* Bring the newest user post flush to the top of the thread and keep it
|
||||
* there while the answer streams in.
|
||||
*
|
||||
* Two things are needed for the post to *really* sit at the top:
|
||||
* 1. no `scroll-margin-top` (a gap would leave the tail of the previous
|
||||
* answer visible above the post);
|
||||
* 2. a short answer cannot fill the viewport on its own, so the thread's
|
||||
* bottom padding is grown by exactly the missing amount — otherwise the
|
||||
* browser clamps the scroll and the post stops half-way down the window.
|
||||
*/
|
||||
_anchorLatestUser(opts = {}) {
|
||||
if (!this._panel) return;
|
||||
@@ -1827,12 +1850,31 @@ class BooksLM {
|
||||
if (!el) return;
|
||||
const behavior = opts.instant ? 'auto' : 'smooth';
|
||||
const apply = () => {
|
||||
// Grow the bottom padding so the anchor position becomes reachable.
|
||||
const base = parseFloat(getComputedStyle(container).paddingBottom) || 0;
|
||||
container.style.paddingBottom = '';
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const target = el.getBoundingClientRect().top - cRect.top + container.scrollTop;
|
||||
const max = container.scrollHeight - container.clientHeight;
|
||||
// Only when the thread really scrolls: a thread shorter than the window
|
||||
// already shows the post at the top, extra padding would just leave a
|
||||
// blank tail.
|
||||
if (max > 0 && target > max) {
|
||||
container.style.paddingBottom = `${Math.ceil(base + (target - max))}px`;
|
||||
} else {
|
||||
container.style.paddingBottom = '';
|
||||
}
|
||||
try {
|
||||
el.scrollIntoView({ behavior, block: 'start' });
|
||||
} catch {
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const eRect = el.getBoundingClientRect();
|
||||
container.scrollTop += eRect.top - cRect.top;
|
||||
container.scrollTop = target;
|
||||
}
|
||||
// Correction pass: `scrollIntoView` can land a few pixels off (scroll
|
||||
// anchoring, sub-pixel padding, momentum scroll). Trust the measured
|
||||
// geometry, not the request.
|
||||
if (behavior === 'auto') {
|
||||
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
if (Math.abs(delta) > 1) container.scrollTop += delta;
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply);
|
||||
|
||||
+5
-3
@@ -9436,10 +9436,12 @@ body.popup-mode .content-area {
|
||||
@keyframes bookslm-spin { to { transform: rotate(360deg); } }
|
||||
/* Notion-style AI thread: only this zone scrolls; entries stack vertically
|
||||
with generous spacing. The latest user post is anchored to the top of the
|
||||
visible area on send (scrollIntoView block:start + scroll-margin). */
|
||||
visible area on send: the post is anchored flush at the top (block:start,
|
||||
and the bottom padding is grown so a short answer can still roll the post
|
||||
all the way up). */
|
||||
.bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex;
|
||||
flex-direction: column; gap: 24px; }
|
||||
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; }
|
||||
flex-direction: column; gap: 24px; overflow-anchor: none; }
|
||||
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; }
|
||||
/* The user bubble is capped against the *thread* width, not against a
|
||||
shrink-to-fit wrapper: capping both would compound (80% × 85% ≈ 68%) and
|
||||
produced very short lines. Wrapper stretches, bubble carries the cap. */
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v12';
|
||||
const SW_VERSION = 'v18';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
Reference in New Issue
Block a user