- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가) - 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지 - 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용 - 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리 Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
4.0 KiB
JavaScript
128 lines
4.0 KiB
JavaScript
/**
|
|
* 덱 목록 무한 스크롤 페이저
|
|
* 필터링된 배열을 화면에 청크 단위로 나눠 렌더링하고,
|
|
* 하단 센티넬이 뷰포트에 근접하면 다음 청크를 이어 붙인다.
|
|
*/
|
|
(function initDeckListPager() {
|
|
const DEFAULT_PAGE_SIZE = 24;
|
|
|
|
/**
|
|
* @typedef {object} DeckPagerOptions
|
|
* @property {HTMLElement} container 목록을 그릴 컨테이너
|
|
* @property {(item: object) => string} renderItem 카드 HTML 생성 함수
|
|
* @property {() => string} [emptyHtml] 항목이 없을 때 표시할 HTML
|
|
* @property {(grid: HTMLElement) => void} [onAppend] 청크 추가 후 콜백
|
|
* @property {string} [gridClass] 그리드 컨테이너 클래스
|
|
* @property {number} [pageSize] 청크당 항목 수
|
|
*/
|
|
|
|
/**
|
|
* 무한 스크롤 페이저 생성
|
|
* @param {DeckPagerOptions} options
|
|
* @returns {{ refresh: (items: object[]) => void }}
|
|
*/
|
|
function create(options) {
|
|
const {
|
|
container,
|
|
renderItem,
|
|
emptyHtml,
|
|
onAppend,
|
|
gridClass = 'deck-grid',
|
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
} = options;
|
|
|
|
/** @type {object[]} */
|
|
let items = [];
|
|
let rendered = 0;
|
|
/** @type {HTMLElement|null} */
|
|
let grid = null;
|
|
/** @type {HTMLElement|null} */
|
|
let sentinel = null;
|
|
/** @type {IntersectionObserver|null} */
|
|
let observer = null;
|
|
|
|
function teardownObserver() {
|
|
if (observer) {
|
|
observer.disconnect();
|
|
observer = null;
|
|
}
|
|
}
|
|
|
|
function removeSentinel() {
|
|
teardownObserver();
|
|
if (sentinel) {
|
|
sentinel.remove();
|
|
sentinel = null;
|
|
}
|
|
}
|
|
|
|
function appendNext() {
|
|
if (!grid) return;
|
|
const next = items.slice(rendered, rendered + pageSize);
|
|
if (next.length) {
|
|
grid.insertAdjacentHTML('beforeend', next.map(renderItem).join(''));
|
|
rendered += next.length;
|
|
if (typeof onAppend === 'function') onAppend(grid);
|
|
}
|
|
if (rendered >= items.length) {
|
|
removeSentinel();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 목록을 처음부터 다시 렌더링
|
|
* @param {object[]} nextItems
|
|
*/
|
|
function refresh(nextItems) {
|
|
items = Array.isArray(nextItems) ? nextItems : [];
|
|
rendered = 0;
|
|
removeSentinel();
|
|
container.innerHTML = '';
|
|
|
|
if (!items.length) {
|
|
container.innerHTML = typeof emptyHtml === 'function' ? emptyHtml() : '';
|
|
grid = null;
|
|
return;
|
|
}
|
|
|
|
grid = document.createElement('div');
|
|
grid.className = gridClass;
|
|
container.appendChild(grid);
|
|
|
|
sentinel = document.createElement('div');
|
|
sentinel.setAttribute('aria-hidden', 'true');
|
|
sentinel.style.cssText = 'width:100%;height:1px;';
|
|
container.appendChild(sentinel);
|
|
|
|
appendNext();
|
|
|
|
if (rendered >= items.length) {
|
|
removeSentinel();
|
|
return;
|
|
}
|
|
|
|
if ('IntersectionObserver' in window) {
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
appendNext();
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: '600px 0px' },
|
|
);
|
|
observer.observe(sentinel);
|
|
} else {
|
|
// IntersectionObserver 미지원 환경은 전체 렌더로 폴백
|
|
while (rendered < items.length) appendNext();
|
|
}
|
|
}
|
|
|
|
return { refresh };
|
|
}
|
|
|
|
window.UaDeckPager = { create };
|
|
})();
|