UNPKG

12.6 kBPlain TextView Raw
1import Component from "@egjs/component";
2import { diff } from "@egjs/list-differ";
3import { DIRECTION } from "./consts";
4import { findIndex, findLastIndex, getNextCursors, isFlatOutline } from "./utils";
5
6export interface OnInfiniteRequestAppend {
7 key?: string | number | undefined;
8 nextKey?: string | number | undefined;
9 nextKeys?: Array<string | number>;
10 isVirtual: boolean;
11}
12
13export interface OnInfiniteRequestPrepend {
14 key?: string | number;
15 nextKey?: string | number;
16 nextKeys?: Array<string | number>;
17 isVirtual: boolean;
18}
19
20export interface OnInfiniteChange {
21 prevStartCursor: number;
22 prevEndCursor: number;
23 nextStartCursor: number;
24 nextEndCursor: number;
25}
26
27export interface InfiniteEvents {
28 requestAppend: OnInfiniteRequestAppend;
29 requestPrepend: OnInfiniteRequestPrepend;
30 change: OnInfiniteChange;
31}
32
33export interface InfiniteOptions {
34 useRecycle?: boolean;
35 threshold?: number;
36 defaultDirection?: "start" | "end";
37}
38
39export interface InfiniteItem {
40 key: string | number;
41 startOutline: number[];
42 endOutline: number[];
43 isVirtual?: boolean;
44}
45
46export class Infinite extends Component<InfiniteEvents> {
47 public options: Required<InfiniteOptions>;
48 protected startCursor = -1;
49 protected endCursor = -1;
50 protected size = 0;
51 protected items: InfiniteItem[] = [];
52 protected itemKeys: Record<string | number, InfiniteItem> = {};
53 constructor(options: InfiniteOptions) {
54 super();
55 this.options = {
56 threshold: 0,
57 useRecycle: true,
58 defaultDirection: "end",
59 ...options,
60 };
61 }
62 public scroll(scrollPos: number) {
63 const prevStartCursor = this.startCursor;
64 const prevEndCursor = this.endCursor;
65 const items = this.items;
66 const length = items.length;
67 const size = this.size;
68 const {
69 defaultDirection,
70 threshold,
71 useRecycle,
72 } = this.options;
73 const isDirectionEnd = defaultDirection === "end";
74
75 if (!length) {
76 this.trigger(isDirectionEnd ? "requestAppend" : "requestPrepend", {
77 key: undefined,
78 isVirtual: false,
79 });
80 return;
81 } else if (prevStartCursor === -1 || prevEndCursor === -1) {
82 const nextCursor = isDirectionEnd ? 0 : length - 1;
83 this.trigger("change", {
84 prevStartCursor,
85 prevEndCursor,
86 nextStartCursor: nextCursor,
87 nextEndCursor: nextCursor,
88 });
89 return;
90 }
91
92 const endScrollPos = scrollPos + size;
93 const startEdgePos = Math.max(...items[prevStartCursor].startOutline);
94 const endEdgePos = Math.min(...items[prevEndCursor].endOutline);
95 const visibles = items.map((item) => {
96 const {
97 startOutline,
98 endOutline,
99 } = item;
100
101 if (!startOutline.length || !endOutline.length || isFlatOutline(startOutline, endOutline)) {
102 return false;
103 }
104 const startPos = Math.min(...startOutline);
105 const endPos = Math.max(...endOutline);
106
107 if (startPos - threshold <= endScrollPos && scrollPos <= endPos + threshold) {
108 return true;
109 }
110 return false;
111 });
112 const hasStartItems = 0 < prevStartCursor;
113 const hasEndItems = prevEndCursor < length - 1;
114 const isStart = scrollPos <= startEdgePos + threshold;
115 const isEnd = endScrollPos >= endEdgePos - threshold;
116 let nextStartCursor = visibles.indexOf(true);
117 let nextEndCursor = visibles.lastIndexOf(true);
118
119 if (nextStartCursor === -1) {
120 nextStartCursor = prevStartCursor;
121 nextEndCursor = prevEndCursor;
122 }
123
124 if (!useRecycle) {
125 nextStartCursor = Math.min(nextStartCursor, prevStartCursor);
126 nextEndCursor = Math.max(nextEndCursor, prevEndCursor);
127 }
128 if (nextStartCursor === prevStartCursor && hasStartItems && isStart) {
129 nextStartCursor -= 1;
130 }
131 if (nextEndCursor === prevEndCursor && hasEndItems && isEnd) {
132 nextEndCursor += 1;
133 }
134 let nextVisibleItems = items.slice(nextStartCursor, nextEndCursor + 1);
135
136 // It must contain no virtual items.
137 if (nextVisibleItems.every((item) => item.isVirtual === true)) {
138 // The real item can be in either the start or end direction.
139 let hasRealItem = false;
140
141 for (let i = nextStartCursor - 1; i >= 0; --i) {
142 if (!items[i].isVirtual) {
143 nextStartCursor = i;
144 hasRealItem = true;
145 break;
146 }
147 }
148 if (!hasRealItem) {
149 for (let i = nextEndCursor + 1; i < length; ++i) {
150 if (!items[i].isVirtual) {
151 nextEndCursor = i;
152 hasRealItem = true;
153 break;
154 }
155 }
156 }
157 if (hasRealItem) {
158 nextVisibleItems = items.slice(nextStartCursor, nextEndCursor + 1);
159 }
160 }
161
162 const hasVirtualItems = nextVisibleItems.some((item) => item.isVirtual === true);
163
164 if (prevStartCursor !== nextStartCursor || prevEndCursor !== nextEndCursor) {
165 this.trigger("change", {
166 prevStartCursor,
167 prevEndCursor,
168 nextStartCursor,
169 nextEndCursor,
170 });
171
172 if (!hasVirtualItems) {
173 return;
174 }
175 }
176
177 // If a virtual item is included, a requestPrepend (or requestAppend) event is triggered.
178 if (hasVirtualItems) {
179 const isStartVirtual = nextVisibleItems[0]?.isVirtual;
180 const isEndVirtual = nextVisibleItems[nextVisibleItems.length - 1]?.isVirtual;
181
182 if ((!isDirectionEnd || !isEnd) && isStartVirtual) {
183 const realItemIndex = findIndex(nextVisibleItems, (item) => !item.isVirtual);
184 const endVirtualItemIndex = (realItemIndex === -1 ? nextVisibleItems.length : realItemIndex) - 1;
185
186 if (nextVisibleItems[endVirtualItemIndex]) {
187 this.trigger("requestPrepend", {
188 key: realItemIndex > -1 ? nextVisibleItems[realItemIndex].key : undefined,
189 nextKey: nextVisibleItems[endVirtualItemIndex].key,
190 nextKeys: nextVisibleItems.slice(0, endVirtualItemIndex + 1).map((item) => item.key),
191 isVirtual: true,
192 });
193 }
194 } else if ((isDirectionEnd || !isStart) && isEndVirtual) {
195 const realItemIndex = findLastIndex(nextVisibleItems, (item) => !item.isVirtual);
196 const startVirtualItemIndex = realItemIndex + 1;
197
198 if (nextVisibleItems[startVirtualItemIndex]) {
199 this.trigger("requestAppend", {
200 key: realItemIndex > -1 ? nextVisibleItems[realItemIndex].key : undefined,
201 nextKey: nextVisibleItems[startVirtualItemIndex].key,
202 nextKeys: nextVisibleItems.slice(startVirtualItemIndex).map((item) => item.key),
203 isVirtual: true,
204 });
205 }
206 }
207 } else if (!this._requestVirtualItems()) {
208 if ((!isDirectionEnd || !isEnd) && isStart) {
209 this.trigger("requestPrepend", {
210 key: items[prevStartCursor].key,
211 isVirtual: false,
212 });
213 } else if ((isDirectionEnd || !isStart) && isEnd) {
214 this.trigger("requestAppend", {
215 key: items[prevEndCursor].key,
216 isVirtual: false,
217 });
218 }
219 }
220 }
221
222 /**
223 * Call the requestAppend or requestPrepend event to fill the virtual items.
224 * @ko virtual item을 채우기 위해 requestAppend 또는 requestPrepend 이벤트를 호출합니다.
225 * @return - Whether the event is called. <ko>이벤트를 호출했는지 여부.</ko>
226 */
227 public _requestVirtualItems() {
228 const isDirectionEnd = this.options.defaultDirection === "end";
229 const items = this.items;
230 const totalVisibleItems = this.getVisibleItems();
231 const visibleItems = totalVisibleItems.filter((item) => !item.isVirtual);
232 const totalVisibleLength = totalVisibleItems.length;
233 const visibleLength = visibleItems.length;
234 const startCursor = this.getStartCursor();
235 const endCursor = this.getEndCursor();
236
237 if (visibleLength === totalVisibleLength) {
238 return false;
239 } else if (visibleLength) {
240 const startKey = visibleItems[0].key;
241 const endKey = visibleItems[visibleLength - 1].key;
242 const startIndex = findIndex(items, (item) => item.key === startKey) - 1;
243 const endIndex = findIndex(items, (item) => item.key === endKey) + 1;
244
245 const isEnd = endIndex <= endCursor;
246 const isStart = startIndex >= startCursor;
247
248 // Fill the placeholder with the original item.
249 if ((isDirectionEnd || !isStart) && isEnd) {
250 this.trigger("requestAppend", {
251 key: endKey,
252 nextKey: items[endIndex].key,
253 isVirtual: true,
254 });
255 return true;
256 } else if ((!isDirectionEnd || !isEnd) && isStart) {
257 this.trigger("requestPrepend", {
258 key: startKey,
259 nextKey: items[startIndex].key,
260 isVirtual: true,
261 });
262 return true;
263 }
264 } else if (totalVisibleLength) {
265 const lastItem = totalVisibleItems[totalVisibleLength - 1];
266
267 if (isDirectionEnd) {
268 this.trigger("requestAppend", {
269 nextKey: totalVisibleItems[0].key,
270 isVirtual: true,
271 });
272 } else {
273 this.trigger("requestPrepend", {
274 nextKey: lastItem.key,
275 isVirtual: true,
276 });
277 }
278 return true;
279 }
280 return false;
281 }
282 public setCursors(startCursor: number, endCursor: number) {
283 this.startCursor = startCursor;
284 this.endCursor = endCursor;
285 }
286 public setSize(size: number) {
287 this.size = size;
288 }
289 public getStartCursor() {
290 return this.startCursor;
291 }
292 public getEndCursor() {
293 return this.endCursor;
294 }
295 public isLoading(direction: "start" | "end") {
296 const startCursor = this.startCursor;
297 const endCursor = this.endCursor;
298 const items = this.items;
299 const firstItem = items[startCursor]!;
300 const lastItem = items[endCursor]!;
301 const length = items.length;
302
303 if (
304 direction === DIRECTION.END
305 && endCursor > -1
306 && endCursor < length - 1
307 && !lastItem.isVirtual
308 && !isFlatOutline(lastItem.startOutline, lastItem.endOutline)
309 ) {
310 return false;
311 }
312 if (
313 direction === DIRECTION.START
314 && startCursor > 0
315 && !firstItem.isVirtual
316 && !isFlatOutline(firstItem.startOutline, firstItem.endOutline)
317 ) {
318 return false;
319 }
320 return true;
321 }
322 public setItems(nextItems: InfiniteItem[]) {
323 this.items = nextItems;
324
325 const itemKeys: Record<string | number, InfiniteItem> = {};
326
327 nextItems.forEach((item) => {
328 itemKeys[item.key] = item;
329 });
330 this.itemKeys = itemKeys;
331 }
332 public syncItems(nextItems: InfiniteItem[]) {
333 const prevItems = this.items;
334 const prevStartCursor = this.startCursor;
335 const prevEndCursor = this.endCursor;
336 const {
337 startCursor: nextStartCursor,
338 endCursor: nextEndCursor,
339 } = getNextCursors(
340 this.items.map((item) => item.key),
341 nextItems.map((item) => item.key),
342 prevStartCursor,
343 prevEndCursor,
344 );
345 // sync items between cursors
346 let isChange = nextEndCursor - nextStartCursor !== prevEndCursor - prevStartCursor
347 || (prevStartCursor === -1 || nextStartCursor === -1);
348
349 if (!isChange) {
350 const prevVisibleItems = prevItems.slice(prevStartCursor, prevEndCursor + 1);
351 const nextVisibleItems = nextItems.slice(nextStartCursor, nextEndCursor + 1);
352 const visibleResult = diff(prevVisibleItems, nextVisibleItems, (item) => item.key);
353
354 isChange = visibleResult.added.length > 0
355 || visibleResult.removed.length > 0
356 || visibleResult.changed.length > 0;
357 }
358 this.setItems(nextItems);
359 this.setCursors(nextStartCursor, nextEndCursor);
360 return isChange;
361 }
362 public getItems() {
363 return this.items;
364 }
365 public getVisibleItems() {
366 const startCursor = this.startCursor;
367 const endCursor = this.endCursor;
368
369 if (startCursor === -1) {
370 return [];
371 }
372 return this.items.slice(startCursor, endCursor + 1);
373 }
374 public getItemByKey(key: string | number) {
375 return this.itemKeys[key];
376 }
377 public getRenderedVisibleItems() {
378 const items = this.getVisibleItems();
379 const rendered = items.map(({ startOutline, endOutline }) => {
380 const length = startOutline.length;
381
382 if (length === 0 || length !== endOutline.length) {
383 return false;
384 }
385 return startOutline.some((pos, i) => endOutline[i] !== pos);
386 });
387 const startIndex = rendered.indexOf(true);
388 const endIndex = rendered.lastIndexOf(true);
389
390 return endIndex === -1 ? [] : items.slice(startIndex, endIndex + 1);
391 }
392 public destroy() {
393 this.off();
394 this.startCursor = -1;
395 this.endCursor = -1;
396 this.items = [];
397 this.size = 0;
398 }
399}