import { useRef } from "../use-ref.js"; import { readlineWidth, breakLines } from "../utils.js"; function usePointerPosition({ active, renderedItems, pageSize, loop, }) { const state = useRef({ lastPointer: active, lastActive: undefined, }); const { lastPointer, lastActive } = state.current; const middle = Math.floor(pageSize / 2); const renderedLength = renderedItems.reduce((acc, item) => acc + item.length, 0); const defaultPointerPosition = renderedItems .slice(0, active) .reduce((acc, item) => acc + item.length, 0); let pointer = defaultPointerPosition; if (renderedLength > pageSize) { if (loop) { /** * Creates the next position for the pointer considering an infinitely * looping list of items to be rendered on the page. * * The goal is to progressively move the cursor to the middle position as the user move down, and then keep * the cursor there. When the user move up, maintain the cursor position. */ // By default, keep the cursor position as-is. pointer = lastPointer; if ( // First render, skip this logic. lastActive != null && // Only move the pointer down when the user moves down. lastActive < active && // Check user didn't move up across page boundary. active - lastActive < pageSize) { pointer = Math.min( // Furthest allowed position for the pointer is the middle of the list middle, Math.abs(active - lastActive) === 1 ? Math.min( // Move the pointer at most the height of the last active item. lastPointer + (renderedItems[lastActive]?.length ?? 0), // If the user moved by one item, move the pointer to the natural position of the active item as // long as it doesn't move the cursor up. Math.max(defaultPointerPosition, lastPointer)) : // Otherwise, move the pointer down by the difference between the active and last active item. lastPointer + active - lastActive); } } else { /** * Creates the next position for the pointer considering a finite list of * items to be rendered on a page. * * The goal is to keep the pointer in the middle of the page whenever possible, until * we reach the bounds of the list (top or bottom). In which case, the cursor moves progressively * to the bottom or top of the list. */ const spaceUnderActive = renderedItems .slice(active) .reduce((acc, item) => acc + item.length, 0); pointer = spaceUnderActive < pageSize - middle ? // If the active item is near the end of the list, progressively move the cursor towards the end. pageSize - spaceUnderActive : // Otherwise, progressively move the pointer to the middle of the list. Math.min(defaultPointerPosition, middle); } } // Save state for the next render state.current.lastPointer = pointer; state.current.lastActive = active; return pointer; } export function usePagination({ items, active, renderItem, pageSize, loop = true, }) { const width = readlineWidth(); const bound = (num) => ((num % items.length) + items.length) % items.length; const renderedItems = items.map((item, index) => { if (item == null) return []; return breakLines(renderItem({ item, index, isActive: index === active }), width).split('\n'); }); const renderedLength = renderedItems.reduce((acc, item) => acc + item.length, 0); const renderItemAtIndex = (index) => renderedItems[index] ?? []; const pointer = usePointerPosition({ active, renderedItems, pageSize, loop }); // Render the active item to decide the position. // If the active item fits under the pointer, we render it there. // Otherwise, we need to render it to fit at the bottom of the page; moving the pointer up. const activeItem = renderItemAtIndex(active).slice(0, pageSize); const activeItemPosition = pointer + activeItem.length <= pageSize ? pointer : pageSize - activeItem.length; // Create an array of lines for the page, and add the lines of the active item into the page const pageBuffer = Array.from({ length: pageSize }); pageBuffer.splice(activeItemPosition, activeItem.length, ...activeItem); // Store to prevent rendering the same item twice const itemVisited = new Set([active]); // Fill the page under the active item let bufferPointer = activeItemPosition + activeItem.length; let itemPointer = bound(active + 1); while (bufferPointer < pageSize && !itemVisited.has(itemPointer) && (loop && renderedLength > pageSize ? itemPointer !== active : itemPointer > active)) { const lines = renderItemAtIndex(itemPointer); const linesToAdd = lines.slice(0, pageSize - bufferPointer); pageBuffer.splice(bufferPointer, linesToAdd.length, ...linesToAdd); // Move pointers for next iteration itemVisited.add(itemPointer); bufferPointer += linesToAdd.length; itemPointer = bound(itemPointer + 1); } // Fill the page over the active item bufferPointer = activeItemPosition - 1; itemPointer = bound(active - 1); while (bufferPointer >= 0 && !itemVisited.has(itemPointer) && (loop && renderedLength > pageSize ? itemPointer !== active : itemPointer < active)) { const lines = renderItemAtIndex(itemPointer); const linesToAdd = lines.slice(Math.max(0, lines.length - bufferPointer - 1)); pageBuffer.splice(bufferPointer - linesToAdd.length + 1, linesToAdd.length, ...linesToAdd); // Move pointers for next iteration itemVisited.add(itemPointer); bufferPointer -= linesToAdd.length; itemPointer = bound(itemPointer - 1); } return pageBuffer.filter((line) => typeof line === 'string').join('\n'); }