@k2b/ui 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/content/FileGrid.js +28 -8
- package/dist/browser/content/FileGrid.js.map +1 -1
- package/dist/browser/content/MarkdownView.js +21 -7
- package/dist/browser/content/MarkdownView.js.map +1 -1
- package/dist/browser/content/MessageRow.js +1250 -0
- package/dist/browser/content/MessageRow.js.map +1 -0
- package/dist/browser/content/VirtualFeed.js +1127 -0
- package/dist/browser/content/VirtualFeed.js.map +1 -0
- package/dist/browser/content/collection-selection.js +27 -11
- package/dist/browser/content/collection-selection.js.map +1 -1
- package/dist/browser/content/index.js +3 -0
- package/dist/browser/content/index.js.map +1 -1
- package/dist/browser/content/markdown-info-blocks.js +363 -0
- package/dist/browser/content/markdown-info-blocks.js.map +1 -0
- package/dist/browser/feedback/InstallGuide.js +199 -145
- package/dist/browser/feedback/InstallGuide.js.map +1 -1
- package/dist/browser/feedback/dialog-core.js +6 -1
- package/dist/browser/feedback/dialog-core.js.map +1 -1
- package/dist/browser/feedback/install.js +8 -4
- package/dist/browser/feedback/install.js.map +1 -1
- package/dist/browser/inputs/QrScanner.js +101 -21
- package/dist/browser/inputs/QrScanner.js.map +1 -1
- package/dist/browser/intl/messages.js +92 -0
- package/dist/browser/intl/messages.js.map +1 -1
- package/dist/browser/layout/MobileShell.js +48 -6
- package/dist/browser/layout/MobileShell.js.map +1 -1
- package/dist/browser/layout/TabBar.js +8 -4
- package/dist/browser/layout/TabBar.js.map +1 -1
- package/dist/browser/surfaces/Avatar.js +33 -17
- package/dist/browser/surfaces/Avatar.js.map +1 -1
- package/dist/ssr/content/FileGrid.js +17 -3
- package/dist/ssr/content/FileGrid.js.map +1 -1
- package/dist/ssr/content/MarkdownView.js +21 -7
- package/dist/ssr/content/MarkdownView.js.map +1 -1
- package/dist/ssr/content/MessageRow.js +796 -0
- package/dist/ssr/content/MessageRow.js.map +1 -0
- package/dist/ssr/content/VirtualFeed.js +989 -0
- package/dist/ssr/content/VirtualFeed.js.map +1 -0
- package/dist/ssr/content/collection-selection.js +27 -11
- package/dist/ssr/content/collection-selection.js.map +1 -1
- package/dist/ssr/content/index.js +3 -0
- package/dist/ssr/content/index.js.map +1 -1
- package/dist/ssr/content/markdown-info-blocks.js +363 -0
- package/dist/ssr/content/markdown-info-blocks.js.map +1 -0
- package/dist/ssr/feedback/InstallGuide.js +150 -98
- package/dist/ssr/feedback/InstallGuide.js.map +1 -1
- package/dist/ssr/feedback/dialog-core.js +6 -1
- package/dist/ssr/feedback/dialog-core.js.map +1 -1
- package/dist/ssr/feedback/install.js +8 -4
- package/dist/ssr/feedback/install.js.map +1 -1
- package/dist/ssr/inputs/QrScanner.js +101 -21
- package/dist/ssr/inputs/QrScanner.js.map +1 -1
- package/dist/ssr/intl/messages.js +92 -0
- package/dist/ssr/intl/messages.js.map +1 -1
- package/dist/ssr/layout/MobileShell.js +48 -6
- package/dist/ssr/layout/MobileShell.js.map +1 -1
- package/dist/ssr/layout/TabBar.js +3 -2
- package/dist/ssr/layout/TabBar.js.map +1 -1
- package/dist/ssr/surfaces/Avatar.js +14 -1
- package/dist/ssr/surfaces/Avatar.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/types/content/FileGrid.d.ts +6 -1
- package/dist/types/content/MarkdownView.d.ts +14 -2
- package/dist/types/content/MessageRow.d.ts +168 -0
- package/dist/types/content/VirtualFeed.d.ts +81 -0
- package/dist/types/content/collection-selection.d.ts +15 -1
- package/dist/types/content/index.d.ts +5 -0
- package/dist/types/content/markdown-info-blocks.d.ts +67 -0
- package/dist/types/feedback/InstallGuide.d.ts +5 -3
- package/dist/types/feedback/install.d.ts +4 -3
- package/dist/types/inputs/QrScanner.d.ts +10 -2
- package/dist/types/intl/messages.d.ts +46 -0
- package/dist/types/layout/TabBar.d.ts +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import { memo as _$memo } from "solid-js/web";
|
|
2
|
+
import { ssr as _$ssr } from "solid-js/web";
|
|
3
|
+
import { createComponent as _$createComponent } from "solid-js/web";
|
|
4
|
+
import { ssrStyleProperty as _$ssrStyleProperty } from "solid-js/web";
|
|
5
|
+
import { ssrAttribute as _$ssrAttribute } from "solid-js/web";
|
|
6
|
+
import { escape as _$escape } from "solid-js/web";
|
|
7
|
+
var _tmpl$ = ["<div class=\"k2b-virtual-feed__marker\"><span>", "</span></div>"],
|
|
8
|
+
_tmpl$2 = ["<div class=\"k2b-virtual-feed__item\" role=\"article\"", " style=\"", "\">", "", "", "</div>"],
|
|
9
|
+
_tmpl$3 = ["<div class=\"k2b-virtual-feed__separator\"><span>", "</span></div>"],
|
|
10
|
+
_tmpl$4 = ["<div class=\"k2b-virtual-feed__empty\">", "</div>"],
|
|
11
|
+
_tmpl$5 = ["<div class=\"k2b-virtual-feed__status\" aria-hidden=\"true\"><i class=\"ti ti-loader-2 k2b-spin\"></i>", "</div>"],
|
|
12
|
+
_tmpl$6 = ["<span class=\"k2b-virtual-feed__count\" aria-hidden=\"true\">", "</span>"],
|
|
13
|
+
_tmpl$7 = ["<button type=\"button\" class=\"k2b-virtual-feed__end\"", "><i", " aria-hidden=\"true\"></i>", "", "</button>"],
|
|
14
|
+
_tmpl$8 = ["<div", "><div class=\"k2b-virtual-feed__viewport\" tabindex=\"-1\"><div class=\"k2b-virtual-feed__feed\" role=\"feed\"", "></div></div>", "", "", "<div class=\"k2b-sr-only\" role=\"log\" aria-live=\"polite\" aria-relevant=\"additions\"></div></div>"];
|
|
15
|
+
import { createEffect, createRoot, createSignal, getOwner, on, onCleanup, onMount, Show, untrack } from "solid-js";
|
|
16
|
+
import { useUiMessages } from "../intl/messages";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The tallest loaded window in px. Firefox renders an element taller than about 17.9 M px with a height of 0, so the
|
|
20
|
+
* feed lays out at most this much and drops far items while it keeps the reading position.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_WINDOW = 8_000_000;
|
|
23
|
+
/** How much the loaded window grows at once when the reader nears one of its edges. */
|
|
24
|
+
const WINDOW_STEP = 2_000_000;
|
|
25
|
+
/** Rows rendered above and below the visible part, in px. */
|
|
26
|
+
const OVERSCAN = 600;
|
|
27
|
+
/** Distance from the end, in px, that still counts as being at the end. */
|
|
28
|
+
const END_TOLERANCE = 2;
|
|
29
|
+
/**
|
|
30
|
+
* Rows mounted during one correction reach the ResizeObserver a frame late; measure up to this many passes at once.
|
|
31
|
+
* A last pass places the rows; the observer measures what that pass mounted.
|
|
32
|
+
*/
|
|
33
|
+
const MEASURE_PASSES = 4;
|
|
34
|
+
const ANNOUNCE_MS = 1_000;
|
|
35
|
+
const HIGHLIGHT_MS = 1_600;
|
|
36
|
+
/** In engines without `scrollend`, the reader's scroll has ended after this long without moving. */
|
|
37
|
+
const QUIET_MS = 300;
|
|
38
|
+
/** The longest wait for the `scrollend` of the reader's scroll after it last moved, or of a write of the feed. */
|
|
39
|
+
const END_WAIT_MS = 1_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One mounted row. Rows are created and removed but never moved, so focus, media, and frames inside them survive.
|
|
43
|
+
* `lead` is the measured height of the separator and marker above the content.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const runsMatch = (before, after, shift) => {
|
|
47
|
+
for (let index = 0; index < before.length; index++) if (before[index] !== after[index + shift]) return false;
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Height of the separator and marker that a row shows above its content. */
|
|
52
|
+
const leadOf = node => {
|
|
53
|
+
let height = 0;
|
|
54
|
+
for (const child of node.children) {
|
|
55
|
+
if (!child.classList.contains("k2b-virtual-feed__separator") && !child.classList.contains("k2b-virtual-feed__marker")) break;
|
|
56
|
+
height += child.getBoundingClientRect().height;
|
|
57
|
+
}
|
|
58
|
+
return height;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A virtualized feed of items with variable heights that grows at both ends and keeps the reading position: it stays
|
|
63
|
+
* at the end while the reader is there, and otherwise keeps the item in view where it is, whatever loads, grows, or
|
|
64
|
+
* resizes around it.
|
|
65
|
+
*/
|
|
66
|
+
export function VirtualFeed(props) {
|
|
67
|
+
const messages = useUiMessages();
|
|
68
|
+
let viewport;
|
|
69
|
+
let feed;
|
|
70
|
+
let log;
|
|
71
|
+
|
|
72
|
+
// Layout: one size per item and prefix sums over the loaded window [lo, hi).
|
|
73
|
+
let list = [];
|
|
74
|
+
let keys = [];
|
|
75
|
+
let sizes = new Float64Array(0);
|
|
76
|
+
let lo = 0;
|
|
77
|
+
let hi = 0;
|
|
78
|
+
let offsets = new Float64Array(1);
|
|
79
|
+
let dirtyFrom = 0;
|
|
80
|
+
|
|
81
|
+
// Reading position: follow the end, or keep one item (index) at a pixel distance (delta) below the visible top.
|
|
82
|
+
let stick = true;
|
|
83
|
+
let anchor = {
|
|
84
|
+
index: 0,
|
|
85
|
+
delta: 0
|
|
86
|
+
};
|
|
87
|
+
/** Key of the last item while no newer items existed; items appended after it are new. */
|
|
88
|
+
let endKey;
|
|
89
|
+
/** The last scrollTop the feed wrote or accounted for; any other value comes from the reader. */
|
|
90
|
+
let lastTop = 0;
|
|
91
|
+
/** The viewport height the reading position was last applied to. */
|
|
92
|
+
let lastHeight = 0;
|
|
93
|
+
/**
|
|
94
|
+
* While the reader scrolls, the feed does not write scrollTop, which would fight a finger, momentum, or a scroll
|
|
95
|
+
* animation. It moves the rows by this much instead: the logical position minus the physical scrollTop.
|
|
96
|
+
*/
|
|
97
|
+
let deferred = 0;
|
|
98
|
+
/** Touches that began in the feed and have not ended. */
|
|
99
|
+
const fingers = new Set();
|
|
100
|
+
/** Where those touches began. Their touchend still goes there after the feed removed the row. */
|
|
101
|
+
const touched = new Set();
|
|
102
|
+
/** The reader's scroll runs, from its first move until its scrollend, or a quiet time where the engine has none. */
|
|
103
|
+
let scrolling = false;
|
|
104
|
+
/** A jump by the application ended the reader's scroll; what is left of its momentum is put back. */
|
|
105
|
+
let overriding = false;
|
|
106
|
+
let lastMove = 0;
|
|
107
|
+
let endedAt = Number.NEGATIVE_INFINITY;
|
|
108
|
+
/**
|
|
109
|
+
* Until then, the next `scrollend` belongs to the feed's last write that moved the position. iOS delivers it late
|
|
110
|
+
* enough to land in a later scroll of the reader, also in a pause of a slow momentum tail.
|
|
111
|
+
*/
|
|
112
|
+
let owedUntil = 0;
|
|
113
|
+
let endTimer;
|
|
114
|
+
let hasScrollEnd = false;
|
|
115
|
+
/** First item appended while the reader scrolls away from the end; reaching the end is judged before it. */
|
|
116
|
+
let appendedFrom = -1;
|
|
117
|
+
let focusIndex = -1;
|
|
118
|
+
let fresh = [];
|
|
119
|
+
let restoring = false;
|
|
120
|
+
let restoreAgain = false;
|
|
121
|
+
let pendingAnnouncement = [];
|
|
122
|
+
let announceTimer;
|
|
123
|
+
let highlightTimer;
|
|
124
|
+
/** "Jump to latest" asked for the newest page; it waits for running loads and stops new paging until it settles. */
|
|
125
|
+
let newest;
|
|
126
|
+
let disposed = false;
|
|
127
|
+
let observeRow = () => {};
|
|
128
|
+
/** Set while the ResizeObserver callback runs. */
|
|
129
|
+
let observing = false;
|
|
130
|
+
let unobserveRow = () => {};
|
|
131
|
+
const owner = getOwner();
|
|
132
|
+
const rows = new Map();
|
|
133
|
+
const rowOf = new WeakMap();
|
|
134
|
+
const [version, setVersion] = createSignal(0);
|
|
135
|
+
/** Changes with every new `items` array, so separators see a replaced predecessor. */
|
|
136
|
+
const [listVersion, setListVersion] = createSignal(0);
|
|
137
|
+
const [tabStop, setTabStop] = createSignal(-1);
|
|
138
|
+
// Known on the server too, so the empty slot never flashes for a feed that has items.
|
|
139
|
+
const [count, setCount] = createSignal(untrack(() => props.items.length));
|
|
140
|
+
const [atEnd, setAtEnd] = createSignal(true);
|
|
141
|
+
const [unseen, setUnseen] = createSignal(0);
|
|
142
|
+
const [highlighted, setHighlighted] = createSignal();
|
|
143
|
+
const [loadingOlder, setLoadingOlder] = createSignal(false);
|
|
144
|
+
const [loadingNewer, setLoadingNewer] = createSignal(false);
|
|
145
|
+
const estimate = item => {
|
|
146
|
+
const size = props.estimateSize(item);
|
|
147
|
+
return size > 0 ? size : 1;
|
|
148
|
+
};
|
|
149
|
+
const recompute = () => {
|
|
150
|
+
const length = hi - lo;
|
|
151
|
+
if (offsets.length !== length + 1) {
|
|
152
|
+
offsets = new Float64Array(length + 1);
|
|
153
|
+
dirtyFrom = 0;
|
|
154
|
+
}
|
|
155
|
+
for (let j = dirtyFrom; j < length; j++) offsets[j + 1] = offsets[j] + sizes[lo + j];
|
|
156
|
+
dirtyFrom = length;
|
|
157
|
+
};
|
|
158
|
+
const total = () => offsets[hi - lo];
|
|
159
|
+
const offsetOf = index => offsets[Math.min(hi - lo, Math.max(0, index - lo))];
|
|
160
|
+
/** The loaded item whose bottom edge lies below `y`. */
|
|
161
|
+
const indexAt = y => {
|
|
162
|
+
let low = 0;
|
|
163
|
+
let high = hi - lo - 1;
|
|
164
|
+
while (low < high) {
|
|
165
|
+
const middle = low + high >> 1;
|
|
166
|
+
if (offsets[middle + 1] <= y) low = middle + 1;else high = middle;
|
|
167
|
+
}
|
|
168
|
+
return lo + Math.max(0, low);
|
|
169
|
+
};
|
|
170
|
+
const anchorAt = top => {
|
|
171
|
+
const index = indexAt(top);
|
|
172
|
+
return {
|
|
173
|
+
index,
|
|
174
|
+
delta: offsetOf(index) - top
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
const logicalTop = () => viewport.scrollTop + deferred;
|
|
178
|
+
const setStick = value => {
|
|
179
|
+
// The anchor is not maintained while following the end; leaving the end keeps what is visible now.
|
|
180
|
+
if (stick && !value && hi > lo) anchor = anchorAt(logicalTop());
|
|
181
|
+
stick = value;
|
|
182
|
+
if (value) setUnseen(0);
|
|
183
|
+
if (atEnd() === value) return;
|
|
184
|
+
setAtEnd(value);
|
|
185
|
+
props.onEndChange?.(value);
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Whether `top` shows the end. It is judged in the geometry the position was last applied to: a viewport resize
|
|
189
|
+
* not yet handled, and items appended while the reader scrolls away from the end, do not count yet.
|
|
190
|
+
*/
|
|
191
|
+
const reachesEnd = top => hi === list.length && !props.hasNewer && (appendedFrom >= 0 ? offsetOf(appendedFrom) : total()) - (top + lastHeight) <= END_TOLERANCE;
|
|
192
|
+
const deferring = () => fingers.size > 0 || scrolling;
|
|
193
|
+
/**
|
|
194
|
+
* Whether the rows may stand in for the logical position `top` while the reader scrolls. Near the start they may
|
|
195
|
+
* not: rows moved down would show empty space above the first item, and rows moved up would put the first items
|
|
196
|
+
* out of the scroll range's reach. The margin is the overscan, so the write lands before the engine draws there.
|
|
197
|
+
*/
|
|
198
|
+
const mayDefer = top => deferring() && Math.min(viewport.scrollTop, top) > OVERSCAN;
|
|
199
|
+
/** Scrolls to `target`, within what the layout can reach, and returns the position the reader gets. */
|
|
200
|
+
const writeTop = (target, immediate) => {
|
|
201
|
+
const next = Math.min(Math.max(0, total() - viewport.clientHeight), Math.max(0, target));
|
|
202
|
+
if (!immediate && mayDefer(next)) {
|
|
203
|
+
// The rows move instead; the scroll range stays the same, so the engine clamps nothing.
|
|
204
|
+
lastTop = viewport.scrollTop;
|
|
205
|
+
deferred = next - lastTop;
|
|
206
|
+
feed.style.height = `${total() - deferred}px`;
|
|
207
|
+
return next;
|
|
208
|
+
}
|
|
209
|
+
deferred = 0;
|
|
210
|
+
feed.style.height = `${total()}px`;
|
|
211
|
+
const from = viewport.scrollTop;
|
|
212
|
+
if (Math.abs(from - next) > 0.5) viewport.scrollTop = next;
|
|
213
|
+
lastTop = viewport.scrollTop;
|
|
214
|
+
if (Math.abs(lastTop - from) > 0.5) owedUntil = performance.now() + END_WAIT_MS;
|
|
215
|
+
return next;
|
|
216
|
+
};
|
|
217
|
+
const updateRange = () => {
|
|
218
|
+
const top = logicalTop();
|
|
219
|
+
const height = viewport.clientHeight;
|
|
220
|
+
const indices = [];
|
|
221
|
+
if (hi > lo) {
|
|
222
|
+
const start = indexAt(top - OVERSCAN);
|
|
223
|
+
const end = indexAt(top + height + OVERSCAN);
|
|
224
|
+
// The focused row stays mounted wherever it is, so focus never falls back to the document.
|
|
225
|
+
const focused = focusIndex >= lo && focusIndex < hi;
|
|
226
|
+
if (focused && focusIndex < start) indices.push(focusIndex);
|
|
227
|
+
for (let index = start; index <= end; index++) indices.push(index);
|
|
228
|
+
if (focused && focusIndex > end) indices.push(focusIndex);
|
|
229
|
+
}
|
|
230
|
+
renderRows(indices);
|
|
231
|
+
setTabStop(focusIndex >= lo && focusIndex < hi ? focusIndex : hi > lo ? indexAt(top + height - 1) : -1);
|
|
232
|
+
};
|
|
233
|
+
const measure = measured => {
|
|
234
|
+
let changed = false;
|
|
235
|
+
for (const [node, size] of measured) {
|
|
236
|
+
const row = rowOf.get(node);
|
|
237
|
+
if (!row || row.index < lo || row.index >= hi) continue;
|
|
238
|
+
const index = row.index;
|
|
239
|
+
// The anchor keeps the content of its row in place: a separator or marker that appears or goes away above it
|
|
240
|
+
// moves the row's top instead.
|
|
241
|
+
const lead = leadOf(node);
|
|
242
|
+
if (!stick && index === anchor.index && row.lead !== undefined) anchor = {
|
|
243
|
+
index,
|
|
244
|
+
delta: anchor.delta + row.lead - lead
|
|
245
|
+
};
|
|
246
|
+
row.lead = lead;
|
|
247
|
+
if (Math.abs(sizes[index] - size) > 0.1) {
|
|
248
|
+
sizes[index] = size;
|
|
249
|
+
dirtyFrom = Math.min(dirtyFrom, index - lo);
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return changed;
|
|
254
|
+
};
|
|
255
|
+
const position = index => props.firstPosition !== undefined ? props.firstPosition + index : props.hasOlder ? undefined : index + 1;
|
|
256
|
+
const setSize = () => props.totalCount ?? (props.hasOlder || props.hasNewer ? -1 : count());
|
|
257
|
+
const createRow = (item, key, at) => createRoot(dispose => {
|
|
258
|
+
const [index, setIndex] = createSignal(at);
|
|
259
|
+
// While older items may come, the first loaded item's predecessor is unknown, so it gets no separator yet.
|
|
260
|
+
const separator = () => (listVersion(), index() === 0 && props.hasOlder ? undefined : props.separator?.(item, list[index() - 1]));
|
|
261
|
+
// Created once, as Solid's For does, so signals that the content reads while rendering never rebuild it.
|
|
262
|
+
const content = untrack(() => props.children(item, index));
|
|
263
|
+
const node = _$ssr(_tmpl$2, _$ssrAttribute("data-index", _$escape(index(), true), false) + _$ssrAttribute("data-key", _$escape(key, true), false) + _$ssrAttribute("data-highlighted", highlighted() === key ? "" : _$escape(undefined, true), false) + _$ssrAttribute("tabindex", index() === tabStop() ? 0 : -1, false) + _$ssrAttribute("aria-label", _$escape(props.itemLabel?.(item), true), false) + _$ssrAttribute("aria-posinset", _$escape(position(index()), true), false) + _$ssrAttribute("aria-setsize", _$escape(setSize(), true), false), _$ssrStyleProperty("transform:", `translateY(${_$escape((version(), offsetOf(index()) - deferred), true)}px)`), _$escape(_$createComponent(Show, {
|
|
264
|
+
get when() {
|
|
265
|
+
return separator();
|
|
266
|
+
},
|
|
267
|
+
children: content => _$ssr(_tmpl$3, _$escape(content()))
|
|
268
|
+
})), _$escape(_$createComponent(Show, {
|
|
269
|
+
get when() {
|
|
270
|
+
return props.markerKey === key;
|
|
271
|
+
},
|
|
272
|
+
get children() {
|
|
273
|
+
return _$ssr(_tmpl$, _$escape(props.markerLabel ?? messages().feedNewMarker));
|
|
274
|
+
}
|
|
275
|
+
})), _$escape(content));
|
|
276
|
+
const row = {
|
|
277
|
+
item,
|
|
278
|
+
node,
|
|
279
|
+
index: at,
|
|
280
|
+
setIndex: next => {
|
|
281
|
+
row.index = next;
|
|
282
|
+
setIndex(next);
|
|
283
|
+
},
|
|
284
|
+
dispose
|
|
285
|
+
};
|
|
286
|
+
return row;
|
|
287
|
+
}, owner);
|
|
288
|
+
|
|
289
|
+
/** Mounts exactly these items in this order; rows that stay keep their node in place. */
|
|
290
|
+
const renderRows = indices => {
|
|
291
|
+
const wanted = new Map();
|
|
292
|
+
for (const index of indices) wanted.set(keys[index], index);
|
|
293
|
+
const active = feed.contains(document.activeElement) ? document.activeElement : undefined;
|
|
294
|
+
let focusedKey;
|
|
295
|
+
for (const [key, row] of rows) {
|
|
296
|
+
const index = wanted.get(key);
|
|
297
|
+
if (index !== undefined && list[index] === row.item) continue;
|
|
298
|
+
if (active && row.node.contains(active)) focusedKey = key;
|
|
299
|
+
unobserveRow(row.node);
|
|
300
|
+
row.node.remove();
|
|
301
|
+
row.dispose();
|
|
302
|
+
rows.delete(key);
|
|
303
|
+
}
|
|
304
|
+
let cursor = feed.firstChild;
|
|
305
|
+
for (const index of indices) {
|
|
306
|
+
const key = keys[index];
|
|
307
|
+
let row = rows.get(key);
|
|
308
|
+
if (row) row.setIndex(index);else {
|
|
309
|
+
row = createRow(list[index], key, index);
|
|
310
|
+
rows.set(key, row);
|
|
311
|
+
rowOf.set(row.node, row);
|
|
312
|
+
observeRow(row.node);
|
|
313
|
+
fresh.push(row.node);
|
|
314
|
+
}
|
|
315
|
+
if (row.node === cursor) cursor = cursor.nextSibling;else feed.insertBefore(row.node, cursor);
|
|
316
|
+
}
|
|
317
|
+
// A reordered item moves its node, which blurs it, and a replaced item gets a new row: focus stays on the item.
|
|
318
|
+
// Only when the item is gone does it fall back to the scroll area.
|
|
319
|
+
if (active && document.activeElement !== active) {
|
|
320
|
+
const replaced = focusedKey === undefined ? undefined : rows.get(focusedKey)?.node;
|
|
321
|
+
(active.isConnected ? active : replaced ?? viewport).focus({
|
|
322
|
+
preventScroll: true
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Puts the reading position back after sizes, items, or the viewport changed: the end while following it, the
|
|
329
|
+
* anchor otherwise. While the reader scrolls, it moves the rows instead of the scroll position, except for
|
|
330
|
+
* `immediate` corrections, which follow structural changes and jumps.
|
|
331
|
+
*/
|
|
332
|
+
const restore = (immediate = false) => {
|
|
333
|
+
if (!viewport) return;
|
|
334
|
+
if (restoring) {
|
|
335
|
+
restoreAgain = true;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
restoring = true;
|
|
339
|
+
let target = 0;
|
|
340
|
+
let top = 0;
|
|
341
|
+
// Following the end shows the end of all items from now on, so later moves are judged against it.
|
|
342
|
+
if (stick) appendedFrom = -1;
|
|
343
|
+
try {
|
|
344
|
+
for (let pass = 0;; pass++) {
|
|
345
|
+
restoreAgain = false;
|
|
346
|
+
recompute();
|
|
347
|
+
target = stick ? total() : offsetOf(anchor.index) - anchor.delta;
|
|
348
|
+
top = writeTop(target, immediate);
|
|
349
|
+
updateRange();
|
|
350
|
+
setVersion(value => value + 1);
|
|
351
|
+
if (pass === MEASURE_PASSES) break;
|
|
352
|
+
// Rows mounted here reach the ResizeObserver only next frame (its loop limit), so measure them now.
|
|
353
|
+
const mounted = fresh.filter(node => node.isConnected);
|
|
354
|
+
fresh = [];
|
|
355
|
+
if (measure(mounted.map(node => [node, node.getBoundingClientRect().height]))) restoreAgain = true;
|
|
356
|
+
if (!restoreAgain) break;
|
|
357
|
+
}
|
|
358
|
+
// A position the layout cannot reach, above its start or past its end, becomes the one the reader sees, so a
|
|
359
|
+
// page that loads there later keeps it instead of moving to it.
|
|
360
|
+
if (!stick && Math.abs(top - target) > 0.5) anchor = {
|
|
361
|
+
index: anchor.index,
|
|
362
|
+
delta: offsetOf(anchor.index) - top
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
restoring = false;
|
|
366
|
+
}
|
|
367
|
+
checkEdges();
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Keeps the reader's new position, reached from `from`. A row that the move reveals at the top can change size in the
|
|
372
|
+
* same frame, before the feed measured it, and the reader never saw it. So the row at the top of `from` keeps its
|
|
373
|
+
* place while it is still in view, and the rows the reader saw move by the reader's step alone.
|
|
374
|
+
*/
|
|
375
|
+
const captureAnchor = from => {
|
|
376
|
+
const top = logicalTop();
|
|
377
|
+
setStick(reachesEnd(top));
|
|
378
|
+
const seen = indexAt(from);
|
|
379
|
+
anchor = offsetOf(seen + 1) > top && offsetOf(seen) < top + viewport.clientHeight ? {
|
|
380
|
+
index: seen,
|
|
381
|
+
delta: offsetOf(seen) - top
|
|
382
|
+
} : anchorAt(top);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Whether scrollTop moved since the feed last wrote or accounted for it. When a size change shortens the scroll
|
|
387
|
+
* range, the engine clamps the position onto the new end; that move is the size change, not the reader.
|
|
388
|
+
*/
|
|
389
|
+
const readerMoved = () => {
|
|
390
|
+
const top = viewport.scrollTop;
|
|
391
|
+
if (Math.abs(top - lastTop) <= 0.5) return false;
|
|
392
|
+
const end = viewport.scrollHeight - viewport.clientHeight;
|
|
393
|
+
return !(lastTop > end + 1 && Math.abs(top - end) <= 1);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Takes a scroll position the feed did not make as the reader's, whatever moved it: touch, momentum, wheel, keys,
|
|
398
|
+
* the scrollbar, focus, scrollIntoView, find in page, or assistive technology. Returns whether there was one.
|
|
399
|
+
*/
|
|
400
|
+
const noteReader = () => {
|
|
401
|
+
if (!readerMoved()) return false;
|
|
402
|
+
lastMove = performance.now();
|
|
403
|
+
if (overriding) {
|
|
404
|
+
// Momentum left from before a jump of the application does not take the reader away again.
|
|
405
|
+
armEnd(QUIET_MS);
|
|
406
|
+
restore(true);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
armEnd();
|
|
410
|
+
const from = lastTop + deferred;
|
|
411
|
+
lastTop = viewport.scrollTop;
|
|
412
|
+
scrolling = true;
|
|
413
|
+
captureAnchor(from);
|
|
414
|
+
return true;
|
|
415
|
+
};
|
|
416
|
+
const armEnd = (ms = hasScrollEnd ? END_WAIT_MS : QUIET_MS) => {
|
|
417
|
+
clearTimeout(endTimer);
|
|
418
|
+
endTimer = setTimeout(() => {
|
|
419
|
+
endTimer = undefined;
|
|
420
|
+
// A finger holds the scroll until it lifts; a move without its scroll event yet continues it.
|
|
421
|
+
if (fingers.size > 0) return;
|
|
422
|
+
if (readerMoved()) noteReader();else endScroll();
|
|
423
|
+
}, ms);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/** The reader's scroll ended: what it held back is written in one step that moves nothing the reader sees. */
|
|
427
|
+
const endScroll = () => {
|
|
428
|
+
clearTimeout(endTimer);
|
|
429
|
+
endTimer = undefined;
|
|
430
|
+
scrolling = false;
|
|
431
|
+
overriding = false;
|
|
432
|
+
appendedFrom = -1;
|
|
433
|
+
restore();
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* A jump of the application ends the reader's scroll in progress: the jump lands at once, and what is left of the
|
|
438
|
+
* momentum is put back until it has been still for a quiet time, or the reader touches, wheels, clicks, or presses
|
|
439
|
+
* a key. A scrollend does not end this: a momentum stopped by the engine can still deliver a step in flight.
|
|
440
|
+
*/
|
|
441
|
+
const takeOver = () => {
|
|
442
|
+
appendedFrom = -1;
|
|
443
|
+
if (!scrolling || fingers.size > 0) return;
|
|
444
|
+
scrolling = false;
|
|
445
|
+
overriding = true;
|
|
446
|
+
lastMove = performance.now();
|
|
447
|
+
armEnd(QUIET_MS);
|
|
448
|
+
// A scroller that cannot scroll drops its momentum where the engine allows that. It scrolls again after a frame
|
|
449
|
+
// in which nothing was left to put back.
|
|
450
|
+
viewport.style.overflowY = "hidden";
|
|
451
|
+
let seen = Number.NaN;
|
|
452
|
+
const check = () => {
|
|
453
|
+
if (disposed) return;
|
|
454
|
+
if (overriding && lastMove !== seen) {
|
|
455
|
+
seen = lastMove;
|
|
456
|
+
requestAnimationFrame(check);
|
|
457
|
+
} else viewport.style.removeProperty("overflow-y");
|
|
458
|
+
};
|
|
459
|
+
requestAnimationFrame(check);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/** Lays out a window of about half the maximum around `center`. */
|
|
463
|
+
const windowAround = center => {
|
|
464
|
+
lo = center;
|
|
465
|
+
hi = center + 1;
|
|
466
|
+
let height = sizes[center];
|
|
467
|
+
while (height < MAX_WINDOW / 2 && (lo > 0 || hi < list.length)) {
|
|
468
|
+
if (lo > 0 && (center - lo <= hi - 1 - center || hi >= list.length)) height += sizes[--lo];else height += sizes[hi++];
|
|
469
|
+
}
|
|
470
|
+
dirtyFrom = 0;
|
|
471
|
+
};
|
|
472
|
+
const dropFocus = () => {
|
|
473
|
+
if (focusIndex >= lo && focusIndex < hi) return;
|
|
474
|
+
const focused = rows.get(keys[focusIndex] ?? "")?.node;
|
|
475
|
+
focusIndex = -1;
|
|
476
|
+
if (focused?.contains(document.activeElement)) viewport.focus({
|
|
477
|
+
preventScroll: true
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Drops items from one side of the window, never those around the reading position, until it fits again. Returns
|
|
483
|
+
* whether it dropped any.
|
|
484
|
+
*/
|
|
485
|
+
const trimWindow = side => {
|
|
486
|
+
recompute();
|
|
487
|
+
let excess = total() - MAX_WINDOW;
|
|
488
|
+
if (excess <= 0) return false;
|
|
489
|
+
const [from, to] = [lo, hi];
|
|
490
|
+
const height = viewport.clientHeight;
|
|
491
|
+
const top = stick ? total() - height : offsetOf(anchor.index) - anchor.delta;
|
|
492
|
+
// While following the end the anchor is not maintained; only the visible end counts.
|
|
493
|
+
const first = Math.min(indexAt(top - height - OVERSCAN), stick ? hi : anchor.index);
|
|
494
|
+
const last = Math.max(indexAt(top + 2 * height + OVERSCAN), stick ? lo : anchor.index);
|
|
495
|
+
if (side === "start") while (excess > 0 && lo < first) excess -= sizes[lo++];else while (excess > 0 && hi - 1 > last) excess -= sizes[--hi];
|
|
496
|
+
dirtyFrom = 0;
|
|
497
|
+
dropFocus();
|
|
498
|
+
return lo !== from || hi !== to;
|
|
499
|
+
};
|
|
500
|
+
const loadNewest = () => {
|
|
501
|
+
if (loadingOlder() || loadingNewer()) {
|
|
502
|
+
newest = "waiting";
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
newest = "loading";
|
|
506
|
+
load("newest");
|
|
507
|
+
};
|
|
508
|
+
const load = direction => {
|
|
509
|
+
const setLoading = direction === "older" ? setLoadingOlder : setLoadingNewer;
|
|
510
|
+
const callback = direction === "older" ? props.onLoadOlder : direction === "newer" ? props.onLoadNewer : props.onLoadNewest;
|
|
511
|
+
if (!callback) return;
|
|
512
|
+
// A load succeeded when its edge moved; other changes to `items` meanwhile do not count.
|
|
513
|
+
const edge = () => direction === "older" ? keys[0] : keys[keys.length - 1];
|
|
514
|
+
const before = edge();
|
|
515
|
+
setLoading(true);
|
|
516
|
+
let result;
|
|
517
|
+
try {
|
|
518
|
+
result = callback();
|
|
519
|
+
} catch {
|
|
520
|
+
result = undefined;
|
|
521
|
+
}
|
|
522
|
+
void Promise.resolve(result).catch(() => undefined).then(() => {
|
|
523
|
+
setLoading(false);
|
|
524
|
+
if (disposed) return;
|
|
525
|
+
if (direction === "newest") newest = undefined;
|
|
526
|
+
if (newest === "waiting") {
|
|
527
|
+
if (!loadingOlder() && !loadingNewer()) loadNewest();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const arrived = edge() !== before;
|
|
531
|
+
// A failed or empty load toward the end shows "Jump to latest" again instead of following an end that never came.
|
|
532
|
+
if (direction !== "older" && !arrived && stick && props.hasNewer) setStick(false);
|
|
533
|
+
// Keep loading while the reader still sits at the edge and the last page arrived.
|
|
534
|
+
if (arrived) checkEdges();
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/** Grows the window or asks for more items when the reader nears an edge. */
|
|
539
|
+
const checkEdges = () => {
|
|
540
|
+
if (hi <= lo || restoring) return;
|
|
541
|
+
const top = logicalTop();
|
|
542
|
+
const height = viewport.clientHeight;
|
|
543
|
+
// Rows measured taller than estimated can grow the window past the cap without any edge moving.
|
|
544
|
+
if (total() > MAX_WINDOW && trimWindow(top + height / 2 > total() / 2 ? "start" : "end")) {
|
|
545
|
+
restore(true);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const margin = Math.max(2_000, 3 * height);
|
|
549
|
+
if (top < margin) {
|
|
550
|
+
if (lo > 0) {
|
|
551
|
+
let added = 0;
|
|
552
|
+
while (lo > 0 && added < WINDOW_STEP) added += sizes[--lo];
|
|
553
|
+
dirtyFrom = 0;
|
|
554
|
+
trimWindow("end");
|
|
555
|
+
restore(true);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (props.hasOlder && !loadingOlder() && !newest) load("older");
|
|
559
|
+
}
|
|
560
|
+
if (total() - top - height < margin) {
|
|
561
|
+
if (hi < list.length) {
|
|
562
|
+
let added = 0;
|
|
563
|
+
while (hi < list.length && added < WINDOW_STEP) added += sizes[hi++];
|
|
564
|
+
dirtyFrom = 0;
|
|
565
|
+
trimWindow("start");
|
|
566
|
+
restore(true);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (props.hasNewer && !loadingNewer() && !newest) load("newer");
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const queueAnnouncement = added => {
|
|
573
|
+
pendingAnnouncement = pendingAnnouncement.concat(added);
|
|
574
|
+
if (announceTimer !== undefined) return;
|
|
575
|
+
announceTimer = setTimeout(() => {
|
|
576
|
+
announceTimer = undefined;
|
|
577
|
+
const batch = pendingAnnouncement;
|
|
578
|
+
pendingAnnouncement = [];
|
|
579
|
+
const text = props.announce ? props.announce(batch) : messages().feedNewItems({
|
|
580
|
+
count: batch.length
|
|
581
|
+
});
|
|
582
|
+
if (!text) return;
|
|
583
|
+
const line = document.createElement("p");
|
|
584
|
+
line.textContent = text;
|
|
585
|
+
log.append(line);
|
|
586
|
+
while (log.childElementCount > 3) log.firstElementChild?.remove();
|
|
587
|
+
}, ANNOUNCE_MS);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/** Applies a new `items` array: appends follow the end, prepends and other changes keep the anchor by key. */
|
|
591
|
+
const sync = next => {
|
|
592
|
+
// The reader may have scrolled in this frame before its scroll event ran; take that position first.
|
|
593
|
+
if (keys.length > 0) noteReader();
|
|
594
|
+
const length = next.length;
|
|
595
|
+
const nextKeys = next.map(props.getKey);
|
|
596
|
+
const previous = keys.length;
|
|
597
|
+
const focusKey = focusIndex >= 0 ? keys[focusIndex] : undefined;
|
|
598
|
+
let added = [];
|
|
599
|
+
let appended = false;
|
|
600
|
+
/** A new feed, or one that shares no item with the last: start at its end. */
|
|
601
|
+
const reset = () => {
|
|
602
|
+
sizes = Float64Array.from(next, estimate);
|
|
603
|
+
focusIndex = -1;
|
|
604
|
+
appendedFrom = -1;
|
|
605
|
+
setStick(!props.hasNewer);
|
|
606
|
+
anchor = {
|
|
607
|
+
index: 0,
|
|
608
|
+
delta: 0
|
|
609
|
+
};
|
|
610
|
+
setUnseen(0);
|
|
611
|
+
// Pending announcements describe the items that are gone.
|
|
612
|
+
clearTimeout(announceTimer);
|
|
613
|
+
announceTimer = undefined;
|
|
614
|
+
pendingAnnouncement = [];
|
|
615
|
+
list = next;
|
|
616
|
+
if (sizes.reduce((sum, size) => sum + size, 0) <= MAX_WINDOW) {
|
|
617
|
+
lo = 0;
|
|
618
|
+
hi = length;
|
|
619
|
+
} else windowAround(length - 1);
|
|
620
|
+
};
|
|
621
|
+
if (previous === 0 || length === 0) reset();else if (length >= previous && runsMatch(keys, nextKeys, 0)) {
|
|
622
|
+
const grown = new Float64Array(length);
|
|
623
|
+
grown.set(sizes);
|
|
624
|
+
for (let index = previous; index < length; index++) grown[index] = estimate(next[index]);
|
|
625
|
+
sizes = grown;
|
|
626
|
+
if (hi === previous) hi = length;
|
|
627
|
+
appended = true;
|
|
628
|
+
// Away from the end, the reader's scroll in progress still reaches the end it was heading for.
|
|
629
|
+
if (!stick && deferring() && appendedFrom < 0) appendedFrom = previous;
|
|
630
|
+
// Only items after the newest one the feed has known are new; older pages toward the end are history.
|
|
631
|
+
const known = endKey === undefined ? -1 : nextKeys.lastIndexOf(endKey);
|
|
632
|
+
if (known >= 0) added = next.slice(Math.max(previous, known + 1));
|
|
633
|
+
} else if (length >= previous && runsMatch(keys, nextKeys, length - previous)) {
|
|
634
|
+
const shift = length - previous;
|
|
635
|
+
const grown = new Float64Array(length);
|
|
636
|
+
grown.set(sizes, shift);
|
|
637
|
+
for (let index = 0; index < shift; index++) grown[index] = estimate(next[index]);
|
|
638
|
+
sizes = grown;
|
|
639
|
+
anchor = {
|
|
640
|
+
index: anchor.index + shift,
|
|
641
|
+
delta: anchor.delta
|
|
642
|
+
};
|
|
643
|
+
if (focusIndex >= 0) focusIndex += shift;
|
|
644
|
+
if (appendedFrom >= 0) appendedFrom += shift;
|
|
645
|
+
if (lo > 0) lo += shift;
|
|
646
|
+
hi += shift;
|
|
647
|
+
} else {
|
|
648
|
+
const known = new Map();
|
|
649
|
+
for (let index = 0; index < previous; index++) known.set(keys[index], sizes[index]);
|
|
650
|
+
const position = new Map();
|
|
651
|
+
for (let index = 0; index < length; index++) position.set(nextKeys[index], index);
|
|
652
|
+
if (!nextKeys.some(key => known.has(key))) reset();else {
|
|
653
|
+
sizes = new Float64Array(length);
|
|
654
|
+
for (let index = 0; index < length; index++) sizes[index] = known.get(nextKeys[index]) ?? estimate(next[index]);
|
|
655
|
+
// An item that is gone hands its role to the nearest surviving neighbor.
|
|
656
|
+
const survivor = from => {
|
|
657
|
+
for (let distance = 0; distance < previous; distance++) {
|
|
658
|
+
const below = position.get(keys[from + distance] ?? "");
|
|
659
|
+
if (below !== undefined) return below;
|
|
660
|
+
const above = position.get(keys[from - distance] ?? "");
|
|
661
|
+
if (above !== undefined) return above;
|
|
662
|
+
}
|
|
663
|
+
return length - 1;
|
|
664
|
+
};
|
|
665
|
+
const wasAtStart = lo === 0;
|
|
666
|
+
const wasAtEnd = hi === previous;
|
|
667
|
+
appendedFrom = -1;
|
|
668
|
+
anchor = {
|
|
669
|
+
index: survivor(anchor.index),
|
|
670
|
+
delta: anchor.delta
|
|
671
|
+
};
|
|
672
|
+
focusIndex = focusKey === undefined ? -1 : position.get(focusKey) ?? -1;
|
|
673
|
+
lo = wasAtStart ? 0 : survivor(lo);
|
|
674
|
+
hi = wasAtEnd ? length : survivor(hi - 1) + 1;
|
|
675
|
+
if (anchor.index < lo || anchor.index >= hi) {
|
|
676
|
+
list = next;
|
|
677
|
+
windowAround(anchor.index);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
list = next;
|
|
682
|
+
keys = nextKeys;
|
|
683
|
+
dirtyFrom = 0;
|
|
684
|
+
setCount(length);
|
|
685
|
+
setListVersion(value => value + 1);
|
|
686
|
+
if (!props.hasNewer) endKey = nextKeys[length - 1];
|
|
687
|
+
recompute();
|
|
688
|
+
const center = stick ? hi - 1 : anchor.index;
|
|
689
|
+
const trimmed = total() > MAX_WINDOW && trimWindow(center - lo > hi - 1 - center ? "start" : "end");
|
|
690
|
+
// Appended items wait for the reader's scroll to end like other size changes; the rows show them meanwhile.
|
|
691
|
+
restore(!appended || trimmed);
|
|
692
|
+
if (!stick && reachesEnd(logicalTop())) setStick(true);
|
|
693
|
+
if (added.length > 0) {
|
|
694
|
+
if (!stick) setUnseen(value => value + added.length);
|
|
695
|
+
queueAnnouncement(added);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Follows the end. With newer items not loaded, `onLoadNewest` replaces the items, after any running load; without
|
|
701
|
+
* it, following the end keeps `onLoadNewer` paging until the newest item arrives or the reader scrolls away.
|
|
702
|
+
*/
|
|
703
|
+
const scrollToEnd = () => {
|
|
704
|
+
takeOver();
|
|
705
|
+
setStick(!props.hasNewer || Boolean(props.onLoadNewer || props.onLoadNewest));
|
|
706
|
+
if (props.hasNewer && props.onLoadNewest) loadNewest();
|
|
707
|
+
if (hi < list.length) windowAround(list.length - 1);
|
|
708
|
+
restore(true);
|
|
709
|
+
};
|
|
710
|
+
const scrollToKey = (key, options = {}) => {
|
|
711
|
+
const index = keys.indexOf(key);
|
|
712
|
+
if (index < 0) return false;
|
|
713
|
+
takeOver();
|
|
714
|
+
if (index < lo || index >= hi) windowAround(index);
|
|
715
|
+
recompute();
|
|
716
|
+
const delta = () => options.align === "start" ? 0 : Math.max(0, (viewport.clientHeight - sizes[index]) / 2);
|
|
717
|
+
setStick(false);
|
|
718
|
+
anchor = {
|
|
719
|
+
index,
|
|
720
|
+
delta: delta()
|
|
721
|
+
};
|
|
722
|
+
restore(true);
|
|
723
|
+
// The row was measured while mounting, before this frame paints; center it on its real height.
|
|
724
|
+
if (anchor.index === index && anchor.delta !== delta()) {
|
|
725
|
+
anchor = {
|
|
726
|
+
index,
|
|
727
|
+
delta: delta()
|
|
728
|
+
};
|
|
729
|
+
restore(true);
|
|
730
|
+
}
|
|
731
|
+
if (reachesEnd(logicalTop())) setStick(true);
|
|
732
|
+
if (options.highlight) {
|
|
733
|
+
clearTimeout(highlightTimer);
|
|
734
|
+
setHighlighted(key);
|
|
735
|
+
highlightTimer = setTimeout(() => setHighlighted(undefined), HIGHLIGHT_MS);
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
};
|
|
739
|
+
const focusRow = target => {
|
|
740
|
+
if (list.length === 0) return;
|
|
741
|
+
const index = Math.min(list.length - 1, Math.max(0, target));
|
|
742
|
+
// An edge load during the restore may add items synchronously and shift indices; the key keeps the target.
|
|
743
|
+
const key = keys[index];
|
|
744
|
+
const outside = index < lo || index >= hi;
|
|
745
|
+
if (outside) windowAround(index);
|
|
746
|
+
recompute();
|
|
747
|
+
focusIndex = index;
|
|
748
|
+
const top = logicalTop();
|
|
749
|
+
const height = viewport.clientHeight;
|
|
750
|
+
const start = offsetOf(index);
|
|
751
|
+
const size = sizes[index];
|
|
752
|
+
if (index === list.length - 1 && hi === list.length && !props.hasNewer) setStick(true);else if (outside) {
|
|
753
|
+
setStick(false);
|
|
754
|
+
anchor = {
|
|
755
|
+
index,
|
|
756
|
+
delta: 0
|
|
757
|
+
};
|
|
758
|
+
} else if (start < top) {
|
|
759
|
+
setStick(false);
|
|
760
|
+
anchor = {
|
|
761
|
+
index,
|
|
762
|
+
delta: 0
|
|
763
|
+
};
|
|
764
|
+
} else if (start + size > top + height) {
|
|
765
|
+
setStick(false);
|
|
766
|
+
anchor = {
|
|
767
|
+
index,
|
|
768
|
+
delta: Math.max(0, height - size)
|
|
769
|
+
};
|
|
770
|
+
restore(true);
|
|
771
|
+
// The row was measured while mounting; align its real bottom before this frame paints.
|
|
772
|
+
if (anchor.index === focusIndex) anchor = {
|
|
773
|
+
index: focusIndex,
|
|
774
|
+
delta: Math.max(0, height - sizes[focusIndex])
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
restore(true);
|
|
778
|
+
rows.get(key)?.node.focus({
|
|
779
|
+
preventScroll: true
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
const onKeyDown = event => {
|
|
783
|
+
const row = rowOf.get(event.target);
|
|
784
|
+
if (!row) return;
|
|
785
|
+
const index = row.index;
|
|
786
|
+
if (event.key === "ArrowUp" || event.key === "PageUp") focusRow(index - 1);else if (event.key === "ArrowDown" || event.key === "PageDown") focusRow(index + 1);else if (event.key === "Home" && event.ctrlKey) focusRow(0);else if (event.key === "End" && event.ctrlKey) {
|
|
787
|
+
scrollToEnd();
|
|
788
|
+
focusRow(list.length - 1);
|
|
789
|
+
} else return;
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
};
|
|
792
|
+
const onScroll = () => {
|
|
793
|
+
// The feed checked its edges when it wrote scrollTop. Its own scroll event arrives a frame later, and checking again
|
|
794
|
+
// then would retry a load that failed in between.
|
|
795
|
+
const reader = noteReader();
|
|
796
|
+
// Near the start, the rows no longer stand in for a held-back correction; it is written now.
|
|
797
|
+
if (deferred !== 0 && !mayDefer(logicalTop())) restore(true);else {
|
|
798
|
+
updateRange();
|
|
799
|
+
if (reader) checkEdges();
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Ends the reader's scroll if the feed stands still for the next two frames. A `scrollend` not counted as a write's
|
|
805
|
+
* can still be the late one of an earlier write; movement after it means the scroll goes on.
|
|
806
|
+
*/
|
|
807
|
+
const confirmEnd = () => {
|
|
808
|
+
const seen = lastMove;
|
|
809
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
810
|
+
if (disposed || fingers.size > 0 || !scrolling || lastMove !== seen || readerMoved()) return;
|
|
811
|
+
endScroll();
|
|
812
|
+
}));
|
|
813
|
+
};
|
|
814
|
+
const onScrollEnd = () => {
|
|
815
|
+
// The scrollend of the feed's own write, not the end of the reader's scroll.
|
|
816
|
+
if (performance.now() < owedUntil) {
|
|
817
|
+
owedUntil = 0;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
endedAt = performance.now();
|
|
821
|
+
if (fingers.size > 0 || !scrolling) return;
|
|
822
|
+
confirmEnd();
|
|
823
|
+
};
|
|
824
|
+
const controller = {
|
|
825
|
+
scrollToKey,
|
|
826
|
+
scrollToEnd,
|
|
827
|
+
isAtEnd: () => stick
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Created before the first sync below, so items that an edge load adds synchronously during it are not lost.
|
|
831
|
+
createEffect(on(() => props.items, sync, {
|
|
832
|
+
defer: true
|
|
833
|
+
}));
|
|
834
|
+
// More items can appear at an edge without new items, for example when a live update reports newer ones.
|
|
835
|
+
createEffect(on([() => props.hasOlder, () => props.hasNewer], () => {
|
|
836
|
+
if (!props.hasNewer) endKey = keys[keys.length - 1];
|
|
837
|
+
if (hi <= lo) return;
|
|
838
|
+
if (!stick && reachesEnd(logicalTop())) setStick(true);
|
|
839
|
+
// Only newer pages can be followed; with just `onLoadNewest`, "Jump to latest" has to show.
|
|
840
|
+
else if (stick && props.hasNewer && !props.onLoadNewer) setStick(false);
|
|
841
|
+
checkEdges();
|
|
842
|
+
}, {
|
|
843
|
+
defer: true
|
|
844
|
+
}));
|
|
845
|
+
onMount(() => {
|
|
846
|
+
hasScrollEnd = "onscrollend" in window;
|
|
847
|
+
lastHeight = viewport.clientHeight;
|
|
848
|
+
const observer = new ResizeObserver(entries => {
|
|
849
|
+
observing = true;
|
|
850
|
+
try {
|
|
851
|
+
// A scroll of the reader in this frame counts first, in the geometry from before these changes.
|
|
852
|
+
noteReader();
|
|
853
|
+
fresh = [];
|
|
854
|
+
let resized = false;
|
|
855
|
+
const measured = [];
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
if (entry.target === viewport) {
|
|
858
|
+
// A shorter viewport (a growing footer or keyboard) keeps what is at its bottom in place.
|
|
859
|
+
const height = viewport.clientHeight;
|
|
860
|
+
if (!stick) anchor = {
|
|
861
|
+
index: anchor.index,
|
|
862
|
+
delta: anchor.delta + height - lastHeight
|
|
863
|
+
};
|
|
864
|
+
lastHeight = height;
|
|
865
|
+
resized = true;
|
|
866
|
+
} else if (entry.target.isConnected) {
|
|
867
|
+
const row = entry.target;
|
|
868
|
+
measured.push([row, entry.borderBoxSize?.[0]?.blockSize ?? row.getBoundingClientRect().height]);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (measure(measured) || resized) restore();
|
|
872
|
+
} finally {
|
|
873
|
+
observing = false;
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
observer.observe(viewport);
|
|
877
|
+
// Rows observed inside the observer's own callback miss its first notification, which raises a window error.
|
|
878
|
+
// They were measured while mounting, so they are observed from the next frame on.
|
|
879
|
+
let later = [];
|
|
880
|
+
let laterFrame = 0;
|
|
881
|
+
observeRow = row => {
|
|
882
|
+
if (!observing) return observer.observe(row);
|
|
883
|
+
later.push(row);
|
|
884
|
+
laterFrame ||= requestAnimationFrame(() => {
|
|
885
|
+
laterFrame = 0;
|
|
886
|
+
for (const node of later) if (node.isConnected) observer.observe(node);
|
|
887
|
+
later = [];
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
unobserveRow = row => observer.unobserve(row);
|
|
891
|
+
const release = () => {
|
|
892
|
+
for (const target of touched) {
|
|
893
|
+
target.removeEventListener("touchend", touchEnd);
|
|
894
|
+
target.removeEventListener("touchcancel", touchEnd);
|
|
895
|
+
}
|
|
896
|
+
touched.clear();
|
|
897
|
+
};
|
|
898
|
+
// Input of the reader ends a takeover by the application at once, and lets the feed scroll for that input.
|
|
899
|
+
const act = () => {
|
|
900
|
+
overriding = false;
|
|
901
|
+
viewport.style.removeProperty("overflow-y");
|
|
902
|
+
};
|
|
903
|
+
const touchStart = event => {
|
|
904
|
+
// A touch whose end the feed missed has left the screen by now.
|
|
905
|
+
const down = new Set(Array.from(event.touches, touch => touch.identifier));
|
|
906
|
+
for (const id of fingers) if (!down.has(id)) fingers.delete(id);
|
|
907
|
+
for (const touch of Array.from(event.changedTouches)) fingers.add(touch.identifier);
|
|
908
|
+
act();
|
|
909
|
+
// Touch events stay with the element the touch began on, also when the feed removes its row meanwhile.
|
|
910
|
+
const target = event.target;
|
|
911
|
+
if (target && !touched.has(target)) {
|
|
912
|
+
touched.add(target);
|
|
913
|
+
target.addEventListener("touchend", touchEnd, {
|
|
914
|
+
passive: true
|
|
915
|
+
});
|
|
916
|
+
target.addEventListener("touchcancel", touchEnd, {
|
|
917
|
+
passive: true
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
function touchEnd(event) {
|
|
922
|
+
for (const touch of Array.from(event.changedTouches)) fingers.delete(touch.identifier);
|
|
923
|
+
if (fingers.size > 0) return;
|
|
924
|
+
release();
|
|
925
|
+
// The last step of a short flick can have moved scrollTop before its scroll event; it counts before anything ends.
|
|
926
|
+
if (!scrolling && !noteReader()) return endScroll();
|
|
927
|
+
armEnd();
|
|
928
|
+
// A scroll that ended under the finger ends now, unless the release starts a momentum.
|
|
929
|
+
if (endedAt >= lastMove) confirmEnd();
|
|
930
|
+
}
|
|
931
|
+
viewport.addEventListener("touchstart", touchStart, {
|
|
932
|
+
passive: true
|
|
933
|
+
});
|
|
934
|
+
viewport.addEventListener("scrollend", onScrollEnd);
|
|
935
|
+
for (const type of ["wheel", "keydown", "pointerdown"]) viewport.addEventListener(type, act, {
|
|
936
|
+
passive: true
|
|
937
|
+
});
|
|
938
|
+
onCleanup(() => {
|
|
939
|
+
disposed = true;
|
|
940
|
+
observer.disconnect();
|
|
941
|
+
cancelAnimationFrame(laterFrame);
|
|
942
|
+
viewport.removeEventListener("touchstart", touchStart);
|
|
943
|
+
viewport.removeEventListener("scrollend", onScrollEnd);
|
|
944
|
+
for (const type of ["wheel", "keydown", "pointerdown"]) viewport.removeEventListener(type, act);
|
|
945
|
+
release();
|
|
946
|
+
clearTimeout(endTimer);
|
|
947
|
+
clearTimeout(announceTimer);
|
|
948
|
+
clearTimeout(highlightTimer);
|
|
949
|
+
for (const row of rows.values()) row.dispose();
|
|
950
|
+
rows.clear();
|
|
951
|
+
});
|
|
952
|
+
sync(props.items);
|
|
953
|
+
props.controller?.(controller);
|
|
954
|
+
});
|
|
955
|
+
const shownCount = () => props.newCount ?? unseen();
|
|
956
|
+
return _$ssr(_tmpl$8, _$ssrAttribute("class", props.class ? `k2b-virtual-feed ${_$escape(props.class, true)}` : "k2b-virtual-feed", false), _$ssrAttribute("aria-label", _$escape(props.label, true), false) + _$ssrAttribute("aria-busy", props.busy || loadingOlder() || loadingNewer() ? "true" : "false", false), _$escape(_$createComponent(Show, {
|
|
957
|
+
get when() {
|
|
958
|
+
return _$memo(() => count() === 0)() && props.empty;
|
|
959
|
+
},
|
|
960
|
+
get children() {
|
|
961
|
+
return _$ssr(_tmpl$4, _$escape(props.empty));
|
|
962
|
+
}
|
|
963
|
+
})), _$escape(_$createComponent(Show, {
|
|
964
|
+
get when() {
|
|
965
|
+
return loadingOlder();
|
|
966
|
+
},
|
|
967
|
+
get children() {
|
|
968
|
+
return _$ssr(_tmpl$5, _$escape(messages().loading));
|
|
969
|
+
}
|
|
970
|
+
})), _$escape(_$createComponent(Show, {
|
|
971
|
+
get when() {
|
|
972
|
+
return _$memo(() => !!!atEnd())() && count() > 0;
|
|
973
|
+
},
|
|
974
|
+
get children() {
|
|
975
|
+
return _$ssr(_tmpl$7, _$ssrAttribute("aria-label", shownCount() > 0 ? _$escape(messages().feedJumpToLatestCount({
|
|
976
|
+
count: shownCount()
|
|
977
|
+
}), true) : _$escape(undefined, true), false), _$ssrAttribute("class", loadingNewer() ? "ti ti-loader-2 k2b-spin" : "ti ti-arrow-down", false), _$escape(messages().feedJumpToLatest), _$escape(_$createComponent(Show, {
|
|
978
|
+
get when() {
|
|
979
|
+
return shownCount() > 0;
|
|
980
|
+
},
|
|
981
|
+
get children() {
|
|
982
|
+
return _$ssr(_tmpl$6, _$escape(shownCount()));
|
|
983
|
+
}
|
|
984
|
+
})));
|
|
985
|
+
}
|
|
986
|
+
})));
|
|
987
|
+
}
|
|
988
|
+
export default VirtualFeed;
|
|
989
|
+
//# sourceMappingURL=VirtualFeed.js.map
|