@discordx/pagination
Version:
Library for creating pagination messages in Discord bots
849 lines (841 loc) • 26 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
Pagination: () => Pagination,
PaginationBuilder: () => PaginationBuilder,
PaginationResolver: () => PaginationResolver,
SelectMenuPageId: () => SelectMenuPageId,
createPagination: () => createPagination,
defaultIds: () => defaultIds,
defaultPerPageItem: () => defaultPerPageItem,
defaultTime: () => defaultTime
});
module.exports = __toCommonJS(index_exports);
// src/pagination/builder.ts
var import_discord = require("discord.js");
// src/utils/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/pagination/builder.ts
var PaginationBuilder = class {
item;
currentPage;
perPage;
skipAmount;
maxPage;
config;
constructor(_item, _currentPage, _maxPage, _config) {
this.item = this.prepareMessage(_item);
this.currentPage = _currentPage;
this.maxPage = _maxPage;
this.config = _config;
this.perPage = _config?.itemsPerPage ?? defaultPerPageItem;
this.skipAmount = _config?.buttons?.skipAmount ?? defaultPerPageItem;
this.validateInputs();
}
validateInputs() {
if (this.currentPage < 0 || this.currentPage >= this.maxPage) {
throw new Error(
`Page ${this.currentPage.toString()} is out of bounds (0-${String(this.maxPage - 1)})`
);
}
if (this.maxPage <= 0) {
throw new Error("Maximum pages must be greater than 0");
}
if (this.config?.buttons?.disabled && this.config.selectMenu?.disabled) {
throw new Error(
"Both navigation buttons and the select menu cannot be disabled at the same time"
);
}
}
prepareMessage(item) {
return {
...item,
attachments: item.attachments ?? [],
components: item.components ?? [],
embeds: item.embeds ?? [],
files: item.files ?? []
};
}
/**
* 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: import_discord.ButtonStyle.Secondary
},
disabled: !states.canGoPrevious,
enabled: true
},
{
key: "backward",
defaults: {
emoji: "\u23EA",
id: defaultIds.buttons.backward,
label: `-${String(Math.min(this.currentPage, this.skipAmount))}`,
style: import_discord.ButtonStyle.Primary
},
disabled: !states.canSkipBackward,
enabled: true
},
{
key: "forward",
defaults: {
emoji: "\u23E9",
id: defaultIds.buttons.forward,
label: `+${String(Math.min(this.maxPage - (this.currentPage + 1), this.skipAmount))}`,
style: import_discord.ButtonStyle.Primary
},
disabled: !states.canSkipForward,
enabled: true
},
{
key: "next",
defaults: {
emoji: "\u25B6\uFE0F",
id: defaultIds.buttons.next,
label: "Next",
style: import_discord.ButtonStyle.Secondary
},
disabled: !states.canGoNext,
enabled: true
},
{
key: "exit",
defaults: {
emoji: "\u2694\uFE0F",
id: defaultIds.buttons.exit,
label: "Stop",
style: import_discord.ButtonStyle.Danger
},
disabled: false,
enabled: false
}
];
const buttons = [];
for (const config of buttonConfigs) {
const userConfig = this.config?.buttons?.[config.key];
const isEnabled = userConfig?.enabled ?? config.enabled;
if (isEnabled) {
buttons.push(this.createButton(config));
}
}
return buttons;
}
createButton(config) {
const userConfig = this.config?.buttons?.[config.key];
const button = new import_discord.ButtonBuilder().setCustomId(userConfig?.id ?? config.defaults.id).setStyle(userConfig?.style ?? config.defaults.style).setDisabled(config.disabled);
const label = userConfig?.label ?? config.defaults.label;
if (label) {
button.setLabel(label);
}
const emoji = userConfig?.emoji ?? config.defaults.emoji;
if (emoji) {
button.setEmoji(emoji);
}
if (!label && !emoji) {
throw Error("Pagination buttons must include either an emoji or a label");
}
return button;
}
getBaseItem() {
return this.item;
}
getPaginatedItem() {
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 import_discord.StringSelectMenuBuilder().setCustomId(this.config?.selectMenu?.menuId ?? defaultIds.menu).setPlaceholder(rangePlaceholder).setOptions(options);
const buttons = this.createNavigationButtons();
const messageComponents = this.item.components ?? [];
const components = [...messageComponents];
if (!this.config?.selectMenu?.disabled) {
components.push({
components: [menu],
type: import_discord.ComponentType.ActionRow
});
}
if (!this.config?.buttons?.disabled) {
components.push({
components: buttons,
type: import_discord.ComponentType.ActionRow
});
}
return { ...this.item, components };
}
};
// src/pagination/pagination.ts
var import_discord2 = require("discord.js");
var import_cloneDeep = __toESM(require("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.buttons?.exit?.enabled) {
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) {
throw new Error(
`Page ${String(page)} is out of bounds (0-${String(this.maxLength - 1)})`
);
}
const item = Array.isArray(this.pages) ? (0, import_cloneDeep.default)(this.pages[page]) : await this.pages.resolver(page, this);
if (!item) {
throw new Error(`No content found for page ${page.toString()}`);
}
const pagination = new PaginationBuilder(
item,
page,
this.maxLength,
this.config
);
return pagination;
};
/**
* 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 page = await this.getPage(this.currentPage);
const message = await this.sendMessage(page.getPaginatedItem());
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 page = await this.getPage(this.currentPage);
await interaction.editReply(page.getBaseItem());
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);
await interaction.editReply(page.getPaginatedItem());
} catch (error) {
this.unableToUpdate(error);
}
}
/**
* Send message via interaction (reply or followUp)
*/
async sendInteractionMessage(message) {
const interaction = this.sendTo;
if (interaction.deferred || interaction.replied) {
this._isFollowUp = true;
}
const messageOptions = {
...message,
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 message2 = response.resource?.message;
if (!message2) {
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 message2;
}
}
/**
* Send message based on sendTo type
*/
async sendMessage(message) {
if (this.sendTo instanceof import_discord2.Message) {
return await this.sendTo.reply(message);
}
if (this.sendTo instanceof import_discord2.CommandInteraction || this.sendTo instanceof import_discord2.MessageComponentInteraction || this.sendTo instanceof import_discord2.ContextMenuCommandInteraction) {
return await this.sendInteractionMessage(message);
}
if (this.sendTo.type === import_discord2.ChannelType.GuildStageVoice) {
throw new Error("Pagination not supported with guild stage channel");
}
return await this.sendTo.send(message);
}
//#endregion
//#region Private - Collector Management
/**
* Create and configure the collectors
*/
createCollector(message) {
const buttonCollector = message.createMessageComponentCollector({
...this.config,
componentType: import_discord2.ComponentType.Button,
time: this.getTime()
});
const menuCollector = message.createMessageComponentCollector({
...this.config,
componentType: import_discord2.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();
}
});
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 === -1 /* Start */) {
return this.navigateToStart();
} else if (selectedValue === -2 /* End */) {
return this.navigateToEnd();
} else {
return this.navigateToPage(selectedValue);
}
}
/**
* Handle collector end event
*/
async handleCollectorEnd() {
if (!this.message) return;
try {
const page = await this.getPage(this.currentPage);
if (this.message.editable) {
if (this.config?.ephemeral && this.sendTo instanceof import_discord2.ChatInputCommandInteraction && !this._isFollowUp) {
await this.sendTo.editReply(page.getBaseItem());
} else {
await this.message.edit(page.getBaseItem());
}
}
if (this.config?.onTimeout) {
this.config.onTimeout(this.currentPage, this.message);
}
} catch (error) {
this.unableToUpdate(error);
}
}
//#endregion
};
// src/pagination/resolver.ts
var PaginationResolver = class {
constructor(resolver, maxLength) {
this.resolver = resolver;
this.maxLength = maxLength;
}
};
// src/pagination/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 || {});
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Pagination,
PaginationBuilder,
PaginationResolver,
SelectMenuPageId,
createPagination,
defaultIds,
defaultPerPageItem,
defaultTime
});