Commit 1b586ca05859
tarball3 files changed+135-26
@@ -70,6 +70,7 @@
7070
import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';7171
import type { PendingMessage } from './lib/pending';7272
import { insideOpenFence } from './lib/markdown';73
+ import { TimelineViewport } from './lib/timeline-viewport';7374
import { markMentions } from './lib/composer-marks';7475
import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';7576
import type { Mention, MentionQuery } from './lib/mentions';@@ -119,6 +120,7 @@
119120
let drafts: Record<string, string> = {};120121
let stickToBottom = true;121122
let timelineEl: HTMLElement | null = null;123
+ let timelineViewport: TimelineViewport | null = null;122124
let composerEl: HTMLTextAreaElement | null = null;123125
let marksEl: HTMLDivElement | null = null;124126
$: composerMarks = markMentions(@@ -604,40 +606,42 @@
604606
}605607
606608
function scrollToBottom(): void {607
- if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;609
+ if (timelineViewport?.element === timelineEl) timelineViewport.scrollToBottom();610
+ else if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;608611
}609612
610
- let userScrollUntil = 0;611
- let draggingScrollbar = false;612
- let paginationAnchor: { roomId: string; top: number; height: number } | null = null;613
-614613
function noteUserScroll(): void {615
- userScrollUntil = performance.now() + 300;614
+ timelineViewport?.releaseScroll();616615
}617616
618617
function onTimelineKey(e: KeyboardEvent): void {619618
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();620619
}621620
622
- function onTimelinePointer(e: PointerEvent): void {623
- if (e.target === timelineEl) draggingScrollbar = true;624
- }625
-626621
function onTimelineScroll(): void {627622
if (!timelineEl) return;623
+ if (timelineViewport?.consumeScroll()) return;628624
const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;629
- if (gap < 80) stickToBottom = true;630
- else if (draggingScrollbar || performance.now() < userScrollUntil) stickToBottom = false;625
+ stickToBottom = gap < 2;626
+ timelineViewport?.capture();631627
if (timelineEl.scrollTop < 80 && !hasStart && !paginateBusy) void paginate();632628
}633629
634630
function pinTimeline(node: HTMLElement) {631
+ const viewport = new TimelineViewport(node.parentElement!, $selectedRoomId);632
+ timelineViewport = viewport;635633
const observer = new ResizeObserver(() => {636
- if (stickToBottom) scrollToBottom();634
+ if (timelineViewport !== viewport || viewport.roomId !== $selectedRoomId) return;635
+ if (stickToBottom) viewport.scrollToBottom(true);636
+ else viewport.restore();637637
});638638
observer.observe(node);639639
if (node.parentElement) observer.observe(node.parentElement);640
- return { destroy: () => observer.disconnect() };640
+ return { destroy: () => {641
+ observer.disconnect();642
+ viewport.releaseScroll();643
+ if (timelineViewport === viewport) timelineViewport = null;644
+ } };641645
}642646
643647
$: void trackTimeline($selectedRoomId, items);@@ -645,10 +649,9 @@
645649
if (!roomId || list.length === 0) return;646650
if (roomId !== $selectedRoomId) return;647651
await tick();648
- if (paginationAnchor?.roomId === roomId && timelineEl && !stickToBottom) {649
- timelineEl.scrollTop = paginationAnchor.top + timelineEl.scrollHeight - paginationAnchor.height;650
- paginationAnchor = null;651
- } else if (stickToBottom) scrollToBottom();652
+ if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;653
+ if (stickToBottom) timelineViewport.scrollToBottom(true);654
+ else timelineViewport.restore();652655
queueReadReceipt();653656
}654657
@@ -1037,6 +1040,7 @@
10371040
return;10381041
}10391042
stickToBottom = false;1043
+ timelineViewport?.releaseScroll();10401044
el.scrollIntoView({ block: 'center', behavior: 'smooth' });10411045
el.classList.remove('flash');10421046
void el.offsetWidth;@@ -1059,10 +1063,12 @@
10591063
10601064
$: void trackPending($selectedRoomId, pendingItems);10611065
async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {1062
- if (!roomId || list.length === 0) return;1066
+ if (!roomId) return;10631067
if (roomId !== $selectedRoomId) return;10641068
await tick();1065
- if (stickToBottom) scrollToBottom();1069
+ if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;1070
+ if (stickToBottom) timelineViewport.scrollToBottom(true);1071
+ else timelineViewport.restore();10661072
}10671073
10681074
let commandQuery: string | null = null;@@ -1152,6 +1158,7 @@
11521158
}11531159
11541160
function onComposerKey(ev: KeyboardEvent): void {1161
+ if (ev.isComposing) return;11551162
if (emoteQuery && emoteCandidates.length > 0) {11561163
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {11571164
ev.preventDefault();@@ -1278,7 +1285,6 @@
12781285
async function paginate(): Promise<void> {12791286
const roomId = $selectedRoomId;12801287
if (!roomId || paginateBusy) return;1281
- if (timelineEl && !stickToBottom) paginationAnchor = { roomId, top: timelineEl.scrollTop, height: timelineEl.scrollHeight };12821288
paginateBusy = true;12831289
try {12841290
await paginateRoom(roomId);@@ -1773,7 +1779,6 @@
17731779
<svelte:window17741780
on:keydown={onKeydown}17751781
on:pointerdown={closeRoomMenuOutside}1776
- on:pointerup={() => (draggingScrollbar = false)}17771782
/>17781783
17791784
{#if !booted}@@ -2356,7 +2361,7 @@
23562361
on:wheel={noteUserScroll}23572362
on:touchmove={noteUserScroll}23582363
on:keydown={onTimelineKey}2359
- on:pointerdown={onTimelinePointer}2364
+ on:pointerdown={noteUserScroll}23602365
aria-label={`Messages in ${selectedRoom.name}`}23612366
>23622367
<div class="timeline-inner" use:pinTimeline>@@ -659,6 +659,7 @@ input, textarea { font: inherit; }
659659
.timeline {660660
flex: 1;661661
overflow-y: auto;662
+ overflow-anchor: none;662663
padding: 8px 14px 10px;663664
min-height: 0;664665
}@@ -818,17 +819,20 @@ input, textarea { font: inherit; }
818819
.composer-field > textarea {819820
resize: none;820821
background: transparent;821
- color: var(--text);822
+ color: transparent;823
+ caret-color: var(--text);824
+ min-width: 0;825
+ width: 100%;822826
outline: none;823827
overflow-y: auto;824828
}825829
.composer-marks {826830
pointer-events: none;827
- color: transparent;831
+ color: var(--text);828832
overflow: hidden;829833
}830834
.composer-marks mark {831
- color: transparent;835
+ color: inherit;832836
background: color-mix(in srgb, var(--accent) 26%, transparent);833837
border-radius: var(--r-sm);834838
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent);@@ -0,0 +1,100 @@
1
+interface MessageAnchor {2
+ itemId: string | undefined;3
+ eventId: string | undefined;4
+ offset: number;5
+}6
+7
+export class TimelineViewport {8
+ private anchors: MessageAnchor[] = [];9
+ private top = 0;10
+ private scrollTarget: number | null = null;11
+ private frame: number | null = null;12
+ private animation: { from: number; to: number; start: number } | null = null;13
+ private initialized = false;14
+15
+ constructor(readonly element: HTMLElement, readonly roomId: string | null) {16
+ this.capture();17
+ }18
+19
+ capture(): void {20
+ const bounds = this.element.getBoundingClientRect();21
+ const top = bounds.top + this.element.clientTop;22
+ const bottom = top + this.element.clientHeight;23
+ this.anchors = [];24
+ this.top = this.element.scrollTop;25
+ for (const row of this.element.querySelectorAll<HTMLElement>('[data-item]')) {26
+ const rect = row.getBoundingClientRect();27
+ if (rect.bottom <= top) continue;28
+ if (rect.top >= bottom) break;29
+ this.anchors.push({ itemId: row.dataset.item, eventId: row.dataset.event, offset: rect.top - top });30
+ if (this.anchors.length === 3) break;31
+ }32
+ }33
+34
+ restore(): void {35
+ this.releaseScroll();36
+ const rows = [...this.element.querySelectorAll<HTMLElement>('[data-item]')];37
+ const top = this.element.getBoundingClientRect().top + this.element.clientTop;38
+ for (const anchor of this.anchors) {39
+ const row = rows.find((row) => row.dataset.item === anchor.itemId) ??40
+ (anchor.eventId ? rows.find((row) => row.dataset.event === anchor.eventId) : undefined);41
+ if (!row) continue;42
+ this.setTop(this.element.scrollTop + row.getBoundingClientRect().top - top - anchor.offset);43
+ this.capture();44
+ return;45
+ }46
+ this.setTop(this.top);47
+ this.capture();48
+ }49
+50
+ scrollToBottom(smooth = false): void {51
+ const target = Math.max(0, this.element.scrollHeight - this.element.clientHeight);52
+ const animate = smooth && this.initialized && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;53
+ this.initialized = true;54
+ if (!animate) {55
+ this.releaseScroll();56
+ this.setTop(target);57
+ this.capture();58
+ return;59
+ }60
+ if (this.animation && Math.abs(this.animation.to - target) < 0.5) return;61
+ if (Math.abs(this.element.scrollTop - target) < 0.5) return;62
+ this.animation = { from: this.element.scrollTop, to: target, start: performance.now() };63
+ if (this.frame === null) this.frame = requestAnimationFrame(this.advance);64
+ }65
+66
+ consumeScroll(): boolean {67
+ const target = this.scrollTarget;68
+ this.scrollTarget = null;69
+ if (target !== null && Math.abs(this.element.scrollTop - target) < 1) return true;70
+ this.releaseScroll();71
+ return false;72
+ }73
+74
+ releaseScroll(): void {75
+ if (this.frame !== null) cancelAnimationFrame(this.frame);76
+ this.frame = null;77
+ this.animation = null;78
+ this.scrollTarget = null;79
+ }80
+81
+ private advance = (now: number): void => {82
+ this.frame = null;83
+ const animation = this.animation;84
+ if (!animation) return;85
+ const progress = Math.min(1, (now - animation.start) / 220);86
+ const eased = 1 - (1 - progress) ** 3;87
+ this.setTop(animation.from + (animation.to - animation.from) * eased);88
+ if (progress < 1) this.frame = requestAnimationFrame(this.advance);89
+ else {90
+ this.animation = null;91
+ this.capture();92
+ }93
+ };94
+95
+ private setTop(top: number): void {96
+ if (Math.abs(this.element.scrollTop - top) < 0.5) return;97
+ this.element.scrollTop = top;98
+ this.scrollTarget = this.element.scrollTop;99
+ }100
+}