UNPKG

@discordx/pagination

Version:

Library for creating pagination messages in Discord bots

833 lines (827 loc) 24.4 kB
// src/functions/GeneratePage.ts import { ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } from "discord.js"; // src/types.ts var defaultTime = 3e5; var defaultPerPageItem = 10; var prefixId = "discordx@pagination@"; var defaultIds = { buttons: { previous: `${prefixId}previous`, backward: `${prefixId}backward`, forward: `${prefixId}forward`, next: `${prefixId}next`, exit: `${prefixId}exit` }, menu: `${prefixId}menu` }; var SelectMenuPageId = /* @__PURE__ */ ((SelectMenuPageId2) => { SelectMenuPageId2[SelectMenuPageId2["Start"] = -1] = "Start"; SelectMenuPageId2[SelectMenuPageId2["End"] = -2] = "End"; return SelectMenuPageId2; })(SelectMenuPageId || {}); // src/functions/Paginate.ts var DEFAULT_CURRENT_PAGE = 0; var DEFAULT_PAGE_SIZE = 10; var DEFAULT_MAX_PAGES = 10; var MIN_PAGE_NUMBER = 0; var MIN_TOTAL_ITEMS = 0; var MIN_PAGE_SIZE = 1; var MIN_MAX_PAGES = 1; function validatePaginationInputs(totalItems, currentPage, pageSize, maxPages) { if (!Number.isInteger(totalItems) || totalItems < MIN_TOTAL_ITEMS) { throw new Error( `Total items must be a non-negative integer, received: ${totalItems.toString()}` ); } if (!Number.isInteger(currentPage) || currentPage < MIN_PAGE_NUMBER) { throw new Error( `Current page must be a non-negative integer, received: ${currentPage.toString()}` ); } if (!Number.isInteger(pageSize) || pageSize < MIN_PAGE_SIZE) { throw new Error( `Page size must be a positive integer, received: ${pageSize.toString()}` ); } if (!Number.isInteger(maxPages) || maxPages < MIN_MAX_PAGES) { throw new Error( `Max pages must be a positive integer, received: ${maxPages.toString()}` ); } } function calculateTotalPages(totalItems, pageSize) { return Math.ceil(totalItems / pageSize); } function normalizeCurrentPage(currentPage, totalPages) { if (totalPages === 0) return 0; return Math.max(MIN_PAGE_NUMBER, Math.min(currentPage, totalPages - 1)); } function calculatePageRange(currentPage, totalPages, maxPages) { if (totalPages <= maxPages) { return { startPage: 0, endPage: totalPages - 1 }; } const maxPagesBeforeCurrentPage = Math.floor(maxPages / 2); const maxPagesAfterCurrentPage = Math.ceil(maxPages / 2) - 1; if (currentPage <= maxPagesBeforeCurrentPage) { return { startPage: 0, endPage: maxPages - 1 }; } if (currentPage + maxPagesAfterCurrentPage >= totalPages) { return { startPage: totalPages - maxPages, endPage: totalPages - 1 }; } return { startPage: currentPage - maxPagesBeforeCurrentPage, endPage: currentPage + maxPagesAfterCurrentPage }; } function calculateItemIndexes(currentPage, pageSize, totalItems) { const startIndex = currentPage * pageSize; const endIndex = Math.min(startIndex + pageSize - 1, totalItems - 1); return { startIndex, endIndex }; } function generatePageNumbers(startPage, endPage) { const pageCount = endPage - startPage + 1; return Array.from({ length: pageCount }, (_, index) => startPage + index); } function createPagination(config) { const { totalItems, currentPage = DEFAULT_CURRENT_PAGE, pageSize = DEFAULT_PAGE_SIZE, maxPages = DEFAULT_MAX_PAGES } = config; try { validatePaginationInputs(totalItems, currentPage, pageSize, maxPages); const totalPages = calculateTotalPages(totalItems, pageSize); const normalizedCurrentPage = normalizeCurrentPage(currentPage, totalPages); const { startPage, endPage } = calculatePageRange( normalizedCurrentPage, totalPages, maxPages ); const { startIndex, endIndex } = calculateItemIndexes( normalizedCurrentPage, pageSize, totalItems ); const pages = generatePageNumbers(startPage, endPage); return { currentPage: normalizedCurrentPage, endIndex, endPage, pageSize, pages, startIndex, startPage, totalItems, totalPages }; } catch (error) { throw new Error( `Pagination creation failed: ${error instanceof Error ? error.message : "Unknown error"}` ); } } // src/functions/GeneratePage.ts var PaginationBuilder = class { item; currentPage; perPage; maxPage; config; constructor(item, currentPage, maxPage, config) { this.validateInputs(currentPage, maxPage); this.item = this.prepareMessage(item); this.currentPage = currentPage; this.maxPage = maxPage; this.config = config; this.perPage = config?.itemsPerPage ?? defaultPerPageItem; } validateInputs(page, maxPage) { if (page < 0 || page >= maxPage) { throw new Error( `Page ${page.toString()} is out of bounds (0-${String(maxPage - 1)})` ); } if (maxPage <= 0) { throw new Error("Maximum pages must be greater than 0"); } } prepareMessage(item) { return { ...item, embeds: item.embeds ?? [], files: item.files ?? [], attachments: item.attachments ?? [] }; } /** * Get the display text for a page */ getPageText(pageNumber) { if (Array.isArray(this.config?.selectMenu?.pageText)) { return this.config.selectMenu.pageText[pageNumber] ?? "Page {page}"; } return this.config?.selectMenu?.pageText ?? "Page {page}"; } /** * Create page-specific options for select menu */ createPageOptions(paginator) { const options = paginator.pages.map((pageNumber) => { const pageText = this.getPageText(pageNumber); return { label: pageText.replace( "{page}", (pageNumber + 1).toString().padStart(2, "0") ), value: pageNumber.toString() }; }); if (paginator.currentPage !== 0) { options.unshift({ label: this.config?.selectMenu?.labels?.start ?? "First page", value: (-1) /* Start */.toString() }); } if (paginator.currentPage !== paginator.totalPages - 1) { options.push({ label: this.config?.selectMenu?.labels?.end ?? "Last page", value: (-2) /* End */.toString() }); } return options; } calculateButtonStates() { return { canGoPrevious: this.currentPage > 0, canSkipBackward: this.currentPage > 0, canSkipForward: this.currentPage < this.maxPage - 1, canGoNext: this.currentPage < this.maxPage - 1 }; } createNavigationButtons() { const states = this.calculateButtonStates(); const buttonConfigs = [ { key: "previous", defaults: { emoji: "\u25C0\uFE0F", id: defaultIds.buttons.previous, label: "Previous", style: ButtonStyle.Secondary }, disabled: !states.canGoPrevious }, { key: "backward", defaults: { emoji: "\u23EA", id: defaultIds.buttons.backward, label: `-${this.perPage.toString()}`, style: ButtonStyle.Primary }, disabled: !states.canSkipBackward }, { key: "forward", defaults: { emoji: "\u23E9", id: defaultIds.buttons.forward, label: `+${this.perPage.toString()}`, style: ButtonStyle.Primary }, disabled: !states.canSkipForward }, { key: "next", defaults: { emoji: "\u25B6\uFE0F", id: defaultIds.buttons.next, label: "Next", style: ButtonStyle.Secondary }, disabled: !states.canGoNext } ]; const buttons = buttonConfigs.map((config) => this.createButton(config)); if (this.config?.enableExit) { buttons.push( this.createButton({ key: "exit", defaults: { emoji: "\u2694\uFE0F", id: defaultIds.buttons.exit, label: "Stop", style: ButtonStyle.Danger }, disabled: false }) ); } return buttons; } createButton(config) { const userConfig = this.config?.buttons?.[config.key]; const button = new ButtonBuilder().setCustomId(userConfig?.id ?? config.defaults.id).setLabel(userConfig?.label ?? config.defaults.label).setStyle(userConfig?.style ?? config.defaults.style).setDisabled(config.disabled); const emoji = userConfig?.emoji ?? config.defaults.emoji; if (emoji) { button.setEmoji(emoji); } return button; } /** * Create navigation button row */ createNavigationButtonRow() { const buttons = this.createNavigationButtons(); return new ActionRowBuilder().addComponents( buttons ); } generate() { const paginator = createPagination({ currentPage: this.currentPage, totalItems: this.maxPage, pageSize: 1, maxPages: this.perPage }); const defaultFormat = "Currently viewing #{start} - #{end} of #{total}"; const format = this.config?.selectMenu?.rangePlaceholderFormat ?? defaultFormat; const rangePlaceholder = format.replace("{start}", (paginator.startPage + 1).toString()).replace("{end}", (paginator.endPage + 1).toString()).replace("{total}", paginator.totalItems.toString()); const options = this.createPageOptions(paginator); const menu = new StringSelectMenuBuilder().setCustomId(this.config?.selectMenu?.menuId ?? defaultIds.menu).setPlaceholder(rangePlaceholder).setOptions(options); const menuRow = new ActionRowBuilder().addComponents([ menu ]); const buttonRow = this.createNavigationButtonRow(); return { newMessage: this.item, components: [menuRow, buttonRow] }; } }; // src/Pagination.ts import { ChannelType, ChatInputCommandInteraction, CommandInteraction, ComponentType, ContextMenuCommandInteraction, Message, MessageComponentInteraction } from "discord.js"; import cloneDeep from "lodash/cloneDeep.js"; var Pagination = class { constructor(sendTo, pages, config) { this.sendTo = sendTo; this.pages = pages; this.config = config; this.maxLength = Array.isArray(pages) ? pages.length : pages.maxLength; this.currentPage = config?.initialPage ?? 0; this.validateConfiguration(); } //#region Properties & Constructor maxLength; currentPage; collectors; message; _isSent = false; _isFollowUp = false; get isSent() { return this._isSent; } //#endregion //#region Configuration & Validation /** * Validate configuration and throw descriptive errors */ validateConfiguration() { if (this.config?.ephemeral && this.config.enableExit) { throw new Error("Ephemeral pagination does not support exit mode"); } if (this.maxLength <= 0) { throw new Error("Pagination must have at least one page"); } if (this.currentPage < 0 || this.currentPage >= this.maxLength) { throw new Error( `Initial page ${this.currentPage.toString()} is out of bounds. Must be between 0 and ${(this.maxLength - 1).toString()}` ); } this.validateButtonOptions(); } /** * Validate button configuration */ validateButtonOptions() { const ids = [ this.getButtonId("previous"), this.getButtonId("backward"), this.getButtonId("forward"), this.getButtonId("next"), this.getButtonId("exit") ]; const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); if (duplicates.length > 0) { throw new Error(`Duplicate button IDs found: ${duplicates.join(", ")}`); } } //#endregion //#region Utility & Helper Methods /** * Log debug messages with consistent formatting */ debug(message) { if (this.config?.debug) { console.log(`[Pagination] ${message}`); } } /** * Handle update errors gracefully */ unableToUpdate(error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; this.debug(`Unable to update pagination: ${errorMessage}`); } /** * Get skip amount */ getSkipAmount() { return this.config?.buttons?.skipAmount ?? defaultPerPageItem; } /** * Get button ID with fallback to default */ getButtonId(buttonType) { return this.config?.buttons?.[buttonType]?.id ?? defaultIds.buttons[buttonType]; } /** * Get menu ID with fallback to default */ getMenuId() { return this.config?.selectMenu?.menuId ?? defaultIds.menu; } /** * Get time with fallback to default */ getTime() { return this.config?.time ?? defaultTime; } //#endregion //#region Public API - Core Functionality /** * Get page */ getPage = async (page) => { if (page < 0 || page >= this.maxLength) { this.debug( `Page ${page.toString()} is out of bounds (0-${String(this.maxLength - 1)})` ); return null; } try { const embed = Array.isArray(this.pages) ? cloneDeep(this.pages[page]) : await this.pages.resolver(page, this); if (!embed) { this.debug(`No content found for page ${page.toString()}`); return null; } const pagination = new PaginationBuilder( embed, page, this.maxLength, this.config ); return pagination.generate(); } catch (error) { this.debug(`Error generating page ${page.toString()}: ${String(error)}`); return null; } }; /** * Send pagination * @returns */ async send() { if (this._isSent) { throw new Error( "Pagination has already been sent. Create a new instance to send again." ); } try { const pageData = await this.prepareInitialMessage(); const message = await this.sendMessage(pageData); const collectors = this.createCollector(message); this.collectors = collectors; this.message = message; this._isSent = true; this.debug( `Pagination sent successfully with ${this.maxLength.toString()} pages` ); return { collectors, message }; } catch (error) { this.debug(`Failed to send pagination: ${String(error)}`); throw new Error( `Failed to send pagination: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * Stop the pagination collector */ stop() { if (this.collectors) { if (!this.collectors.buttonCollector.ended) { this.collectors.buttonCollector.stop(); } if (!this.collectors.menuCollector.ended) { this.collectors.menuCollector.stop(); } this.debug("Pagination stopped manually"); } } //#endregion //#region Public API - Navigation /** * Navigate to a specific page */ navigateToPage(page) { if (page < 0 || page >= this.maxLength) { this.debug( `Cannot navigate to page ${page.toString()}: out of bounds (0-${String(this.maxLength - 1)})` ); return false; } if (page === this.currentPage) { this.debug(`Already on page ${page.toString()}`); return false; } this.currentPage = page; this.debug(`Navigated to page ${page.toString()}`); return true; } /** * Navigate to next page */ navigateNext() { if (this.currentPage >= this.maxLength - 1) { this.debug("Cannot navigate next: already on last page"); return false; } this.currentPage++; this.debug(`Navigated to next page: ${this.currentPage.toString()}`); return true; } /** * Navigate to previous page */ navigatePrevious() { if (this.currentPage <= 0) { this.debug("Cannot navigate previous: already on first page"); return false; } this.currentPage--; this.debug(`Navigated to previous page: ${this.currentPage.toString()}`); return true; } //#endregion //#region Public API - State & Utilities /** * Check if pagination can navigate to next page */ canNavigateNext() { return this.currentPage < this.maxLength - 1; } /** * Check if pagination can navigate to previous page */ canNavigatePrevious() { return this.currentPage > 0; } /** * Get current page info */ getPageInfo() { return { currentPage: this.currentPage, totalPages: this.maxLength, canNext: this.canNavigateNext(), canPrevious: this.canNavigatePrevious(), isFirst: this.currentPage === 0, isLast: this.currentPage === this.maxLength - 1 }; } /** * Navigate to first page */ navigateToStart() { if (this.currentPage === 0) { this.debug("Already on first page"); return false; } this.currentPage = 0; this.debug("Navigated to start page"); return true; } /** * Navigate to last page */ navigateToEnd() { const lastPage = this.maxLength - 1; if (this.currentPage === lastPage) { this.debug("Already on last page"); return false; } this.currentPage = lastPage; this.debug("Navigated to end page"); return true; } //#endregion //#region Private - Message Handling /** * Handle exit */ async handleExit(interaction) { try { await interaction.deferUpdate(); const currentPage = await this.getPage(this.currentPage); if (!currentPage) { throw new Error("Pagination: out of bound page"); } const messageData = { ...currentPage.newMessage, components: currentPage.newMessage.components ?? [] }; await interaction.editReply(messageData); this.stop(); } catch (error) { this.unableToUpdate(error); } } /** * Update the pagination message with current page */ async updatePaginationMessage(interaction) { try { await interaction.deferUpdate(); const page = await this.getPage(this.currentPage); if (!page) { throw new Error("Pagination: out of bound page"); } const components = page.newMessage.components ? [...page.newMessage.components, ...page.components] : [...page.components]; const messageData = { ...page.newMessage, components }; await interaction.editReply(messageData); } catch (error) { this.unableToUpdate(error); } } /** * Prepare initial message with pagination components */ async prepareInitialMessage() { const page = await this.getPage(this.currentPage); if (!page) { throw new Error("Pagination: out of bound page"); } const components = page.newMessage.components ? [...page.newMessage.components, ...page.components] : [...page.components]; return { ...page, newMessage: { ...page.newMessage, components } }; } /** * Send message via interaction (reply or followUp) */ async sendInteractionMessage(pageData) { const interaction = this.sendTo; if (interaction.deferred || interaction.replied) { this._isFollowUp = true; } const messageOptions = { ...pageData.newMessage, ephemeral: this.config?.ephemeral }; if (this._isFollowUp) { const reply = await interaction.followUp({ ...messageOptions, fetchReply: true }); return reply; } else { const response = await interaction.reply({ ...messageOptions, withResponse: true }); const message = response.resource?.message; if (!message) { throw new Error( "Missing Intent: GUILD_MESSAGES\nWithout guild message intent, pagination does not work. Consider adding GUILD_MESSAGES as an intent\nRead more at https://discordx.js.org/docs/faq/Errors/Pagination#missing-intent-guild_messages" ); } return message; } } /** * Send message based on sendTo type */ async sendMessage(pageData) { if (this.sendTo instanceof Message) { return await this.sendTo.reply(pageData.newMessage); } if (this.sendTo instanceof CommandInteraction || this.sendTo instanceof MessageComponentInteraction || this.sendTo instanceof ContextMenuCommandInteraction) { return await this.sendInteractionMessage(pageData); } if (this.sendTo.type === ChannelType.GuildStageVoice) { throw new Error("Pagination not supported with guild stage channel"); } return await this.sendTo.send(pageData.newMessage); } //#endregion //#region Private - Collector Management /** * Create and configure the collectors */ createCollector(message) { const buttonCollector = message.createMessageComponentCollector({ ...this.config, componentType: ComponentType.Button, time: this.getTime() }); const menuCollector = message.createMessageComponentCollector({ ...this.config, componentType: ComponentType.StringSelect, time: this.getTime() }); this.setupCollectorEvents({ buttonCollector, menuCollector }); return { buttonCollector, menuCollector }; } /** * Setup collector event handlers */ setupCollectorEvents({ buttonCollector, menuCollector }) { const resetCollectorTimers = () => { const timerOptions = { idle: this.config?.idle, time: this.getTime() }; buttonCollector.resetTimer(timerOptions); menuCollector.resetTimer(timerOptions); }; buttonCollector.on("collect", async (interaction) => { const shouldContinue = this.handleButtonInteraction(interaction); if (shouldContinue) { await this.updatePaginationMessage(interaction); resetCollectorTimers(); } }); menuCollector.on("collect", async (interaction) => { const shouldContinue = this.handleSelectMenuInteraction(interaction); if (shouldContinue) { await this.updatePaginationMessage(interaction); resetCollectorTimers(); } else { buttonCollector.stop(); menuCollector.stop(); } }); buttonCollector.on("end", () => { menuCollector.stop(); void this.handleCollectorEnd(); }); menuCollector.on("end", () => { buttonCollector.stop(); void this.handleCollectorEnd(); }); } /** * Handle button interaction */ handleButtonInteraction(interaction) { const customId = interaction.customId; if (customId === defaultIds.buttons.exit) { void this.handleExit(interaction); return false; } else if (customId === defaultIds.buttons.previous) { return this.navigatePrevious(); } else if (customId === defaultIds.buttons.next) { return this.navigateNext(); } else if (customId === defaultIds.buttons.backward) { return this.navigateToPage( Math.max(0, this.currentPage - this.getSkipAmount()) ); } else if (customId === defaultIds.buttons.forward) { return this.navigateToPage( Math.min(this.maxLength - 1, this.currentPage + this.getSkipAmount()) ); } return false; } /** * Handle select menu interaction */ handleSelectMenuInteraction(interaction) { if (interaction.customId !== this.getMenuId()) { return false; } const selectedValue = Number(interaction.values[0] ?? 0); if (selectedValue === Number(-1 /* Start */)) { return this.navigateToStart(); } else if (selectedValue === Number(-2 /* End */)) { return this.navigateToEnd(); } else { return this.navigateToPage(selectedValue); } } /** * Handle collector end event */ async handleCollectorEnd() { if (!this.message) return; try { const finalPage = await this.getPage(this.currentPage); if (this.message.editable && finalPage) { finalPage.newMessage.components = []; if (this.config?.ephemeral && this.sendTo instanceof ChatInputCommandInteraction && !this._isFollowUp) { await this.sendTo.editReply(finalPage.newMessage); } else { await this.message.edit(finalPage.newMessage); } } if (this.config?.onTimeout) { this.config.onTimeout(this.currentPage, this.message); } } catch (error) { this.unableToUpdate(error); } } //#endregion }; // src/Resolver.ts var PaginationResolver = class { constructor(resolver, maxLength) { this.resolver = resolver; this.maxLength = maxLength; } }; export { Pagination, PaginationBuilder, PaginationResolver, SelectMenuPageId, createPagination, defaultIds, defaultPerPageItem, defaultTime };