Fix scrolling to targeted message
This commit is contained in:
parent
f5bd6e3852
commit
fe3afa248d
4 changed files with 129 additions and 39 deletions
|
|
@ -236,6 +236,66 @@
|
||||||
|
|
||||||
const getElementKey = (element: {id: string}) => element.id
|
const getElementKey = (element: {id: string}) => element.id
|
||||||
|
|
||||||
|
// The list renders from the newest message outward, so a row deep in history isn't on the page
|
||||||
|
// until it's asked for — and asking renders it on the next flush, which the lookup has to wait
|
||||||
|
// for. Messages carry the id as a data attribute; the new-messages divider as its element id.
|
||||||
|
// Where a row sits inside the scroll container, in screen terms rather than scroll terms, so
|
||||||
|
// the reversed layout doesn't come into it
|
||||||
|
const topOf = (target: HTMLElement) =>
|
||||||
|
target.getBoundingClientRect().top - element!.getBoundingClientRect().top
|
||||||
|
|
||||||
|
// Messages loading in below the pinned row are inserted at the scroll origin, which pushes
|
||||||
|
// everything else away from it. The browser has no reason to compensate for that, so put the
|
||||||
|
// row back where it was. scrollBy is in visual terms, so this reads the same way either way up.
|
||||||
|
const keepPinned = () => {
|
||||||
|
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
|
||||||
|
|
||||||
|
if (target instanceof HTMLElement && pinned) {
|
||||||
|
const drift = topOf(target) - pinned.top
|
||||||
|
|
||||||
|
if (Math.abs(drift) >= 1) {
|
||||||
|
isProgrammaticScroll = true
|
||||||
|
element!.scrollBy({top: drift})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any scroll event at all fires while content loads, so taking over has to be a real gesture
|
||||||
|
const release = () => {
|
||||||
|
released = true
|
||||||
|
pinned = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToRow = (
|
||||||
|
id: string,
|
||||||
|
{
|
||||||
|
behavior = "auto",
|
||||||
|
highlight = false,
|
||||||
|
pin = false,
|
||||||
|
}: {behavior?: ScrollBehavior; highlight?: boolean; pin?: boolean} = {},
|
||||||
|
) => {
|
||||||
|
virtualList?.reveal(id)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const target = element?.querySelector(`[data-event="${id}"]`) ?? document.getElementById(id)
|
||||||
|
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
isProgrammaticScroll = true
|
||||||
|
target.scrollIntoView({behavior, block: "center"})
|
||||||
|
|
||||||
|
if (highlight) {
|
||||||
|
target.classList.add("highlight-target")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pin) {
|
||||||
|
pinned = {id, top: topOf(target)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpSettled = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const manageScrollPosition = () => {
|
const manageScrollPosition = () => {
|
||||||
// Only treat an `at` jump as "scrolled up" when it targets an event below the
|
// Only treat an `at` jump as "scrolled up" when it targets an event below the
|
||||||
// newest one; jumping to the most recent message already lands us at the bottom.
|
// newest one; jumping to the most recent message already lands us at the bottom.
|
||||||
|
|
@ -259,27 +319,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userHasScrolled && !isNaN(at)) {
|
if (!released && !pinned && !isNaN(at)) {
|
||||||
const targetEvent = $events.find(event => event.created_at >= at)
|
const targetEvent = $events.find(event => event.created_at >= at)
|
||||||
|
|
||||||
if (targetEvent) {
|
if (targetEvent) {
|
||||||
// The list renders from the newest message outward, so a jump target deep in history
|
scrollToRow(targetEvent.id, {highlight: true, pin: true})
|
||||||
// may not be on the page yet
|
} else {
|
||||||
virtualList?.reveal(targetEvent.id)
|
// Nothing to jump to yet, so don't hold the room back waiting for it
|
||||||
|
jumpSettled = true
|
||||||
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
|
|
||||||
|
|
||||||
if (target instanceof HTMLElement) {
|
|
||||||
isProgrammaticScroll = true
|
|
||||||
target.scrollIntoView({block: "center"})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (!isProgrammaticScroll) {
|
if (!isProgrammaticScroll) {
|
||||||
userHasScrolled = true
|
|
||||||
isUserScrolling = true
|
isUserScrolling = true
|
||||||
clearIsUserScrolling()
|
clearIsUserScrolling()
|
||||||
manageScrollPosition()
|
manageScrollPosition()
|
||||||
|
|
@ -288,16 +341,7 @@
|
||||||
isProgrammaticScroll = false
|
isProgrammaticScroll = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToNewMessages = () => {
|
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
|
||||||
virtualList?.reveal("new-messages")
|
|
||||||
|
|
||||||
// Revealing it renders it on the next flush, so the scroll has to wait for that
|
|
||||||
requestAnimationFrame(() =>
|
|
||||||
document
|
|
||||||
.getElementById("new-messages")
|
|
||||||
?.scrollIntoView({behavior: "smooth", block: "center"}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (!isNaN(at)) {
|
if (!isNaN(at)) {
|
||||||
|
|
@ -321,7 +365,9 @@
|
||||||
|
|
||||||
let joining = $state(false)
|
let joining = $state(false)
|
||||||
let leaving = $state(false)
|
let leaving = $state(false)
|
||||||
let userHasScrolled = $state(false)
|
let jumpSettled = $state(false)
|
||||||
|
let released = false
|
||||||
|
let pinned: Maybe<{id: string; top: number}>
|
||||||
let isProgrammaticScroll = $state(false)
|
let isProgrammaticScroll = $state(false)
|
||||||
let isUserScrolling = $state(false)
|
let isUserScrolling = $state(false)
|
||||||
let virtualList: Maybe<VirtualListController> = $state()
|
let virtualList: Maybe<VirtualListController> = $state()
|
||||||
|
|
@ -341,6 +387,10 @@
|
||||||
let compose: RoomCompose | undefined = $state()
|
let compose: RoomCompose | undefined = $state()
|
||||||
let eventToEdit: TrustedEvent | undefined = $state()
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
|
|
||||||
|
// A link into history renders the newest messages first and only then scrolls, so the room is
|
||||||
|
// held back for that frame rather than showing the wrong end of the conversation and jumping.
|
||||||
|
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
|
||||||
|
|
||||||
// There is always more history until the feed says otherwise, so this stays up rather than
|
// There is always more history until the feed says otherwise, so this stays up rather than
|
||||||
// blinking between spans while it walks a quiet room.
|
// blinking between spans while it walks a quiet room.
|
||||||
const loadingBackward = $derived($older?.status !== "exhausted")
|
const loadingBackward = $derived($older?.status !== "exhausted")
|
||||||
|
|
@ -451,6 +501,33 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Content can arrive mid-scroll too, so this runs whether or not the reader is moving
|
||||||
|
$effect(() => {
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const frame = requestAnimationFrame(keepPinned)
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bound here rather than in the markup: these watch for the reader taking over, they don't make
|
||||||
|
// the transcript a control, and declaring them as handlers would claim it is one.
|
||||||
|
$effect(() => {
|
||||||
|
if (element) {
|
||||||
|
const target = element
|
||||||
|
|
||||||
|
for (const type of ["wheel", "touchmove", "keydown"]) {
|
||||||
|
target.addEventListener(type, release, {passive: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const type of ["wheel", "touchmove", "keydown"]) {
|
||||||
|
target.removeEventListener(type, release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
cleanup?.()
|
cleanup?.()
|
||||||
|
|
||||||
|
|
@ -557,7 +634,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="relative flex min-h-0 flex-1 flex-col">
|
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||||
<div bind:this={element} onscroll={onScroll} class="room__content scroll-container">
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
onscroll={onScroll}
|
||||||
|
class={cx("room__content scroll-container transition-opacity", {
|
||||||
|
"opacity-0": awaitingJump,
|
||||||
|
})}>
|
||||||
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
||||||
<div class="py-20">
|
<div class="py-20">
|
||||||
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
|
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
|
||||||
|
|
|
||||||
|
|
@ -220,22 +220,14 @@ export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToEvent = (id: string) => {
|
export const scrollToEvent = (id: string) => {
|
||||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
const element = document.querySelector(`[data-event="${id}"]`)
|
||||||
|
|
||||||
if (element) {
|
if (element instanceof HTMLElement) {
|
||||||
element.scrollIntoView({behavior: "smooth", block: "center"})
|
element.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
|
element.classList.add("highlight-target")
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style = "transition-property: all; transition-duration: 300ms;"
|
|
||||||
}, 800)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style = ""
|
|
||||||
}, 800 + 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(element)
|
return element instanceof HTMLElement
|
||||||
}
|
}
|
||||||
|
|
||||||
export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}) => {
|
export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@
|
||||||
// guessed height. Everything on screen is real, so the scrollbar is honest and nothing the
|
// guessed height. Everything on screen is real, so the scrollbar is honest and nothing the
|
||||||
// reader is looking at can shift under them. Guessing at the height of rows that have never
|
// reader is looking at can shift under them. Guessing at the height of rows that have never
|
||||||
// been mounted is what makes a virtualised list lurch, and a list that only grows never has to.
|
// been mounted is what makes a virtualised list lurch, and a list that only grows never has to.
|
||||||
//
|
|
||||||
// The cost is that a long scrollback ends up holding every row it passed, which is what the
|
|
||||||
// list did all the time before. What this buys is the opening render.
|
|
||||||
let edgeKey: Maybe<string> = $state()
|
let edgeKey: Maybe<string> = $state()
|
||||||
|
|
||||||
// Held by key rather than index: messages arriving at the origin shift every index along, and
|
// Held by key rather than index: messages arriving at the origin shift every index along, and
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,25 @@
|
||||||
@utility cv {
|
@utility cv {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
}
|
}
|
||||||
|
/* Marks the event a link pointed at, so the reader can pick it out of the conversation once the
|
||||||
|
jump lands. Brightness rather than a background so it works over any row's own surface. */
|
||||||
|
@utility highlight-target {
|
||||||
|
animation: highlight-target 1.6s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes highlight-target {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.5);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.highlight-target {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@utility pt-sai {
|
@utility pt-sai {
|
||||||
padding-top: var(--sait);
|
padding-top: var(--sait);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue