"use strict";
class BookUtil {
static getHeaderText (header) {
return header.header || header;
}
static scrollClick (ixChapter, headerText, headerNumber) {
headerText = headerText.toLowerCase().trim();
// When handling a non-full-book header in full-book mode, map the header to the appropriate position
if (!~BookUtil.curRender.chapter && ~ixChapter) {
const minHeaderIx = BookUtil.curRender.allViewFirstTitleIndexes[ixChapter];
const maxHeaderIx = BookUtil.curRender.allViewFirstTitleIndexes[ixChapter + 1] == null ? Number.MAX_SAFE_INTEGER : BookUtil.curRender.allViewFirstTitleIndexes[ixChapter + 1];
// Loop through the list of tracked titles, starting at our chapter's first tracked title, until we hit
const trackedTitles = BookUtil._renderer.getTrackedTitles();
const trackedTitleIndexes = Object.keys(trackedTitles).map(it => Number(it)).sort(SortUtil.ascSort);
// Search for a matching header between the current chapter's header start and end
let curHeaderNumber = 0;
const ixTitleUpper = Math.min(trackedTitleIndexes.length, maxHeaderIx) + 1; // +1 since it's 1-indexed
for (let ixTitle = minHeaderIx; ixTitle < ixTitleUpper; ++ixTitle) {
let titleName = trackedTitles[ixTitle];
if (!titleName) return; // Should never occur
titleName = titleName.toLowerCase().trim();
if (titleName === headerText) {
if (curHeaderNumber < headerNumber) {
curHeaderNumber++;
continue;
}
$(`[data-title-index="${ixTitle}"]`)[0].scrollIntoView();
break;
}
}
return;
}
const trackedTitlesInverse = BookUtil._renderer.getTrackedTitlesInverted({isStripTags: true});
const ixTitle = (trackedTitlesInverse[headerText] || [])[headerNumber || 0];
if (ixTitle != null) $(`[data-title-index="${ixTitle}"]`)[0].scrollIntoView();
}
static scrollPageTop (ixChapter) {
// In full-book view, find the Xth section
if (ixChapter != null && !~BookUtil.curRender.chapter) {
const ixTitle = BookUtil.curRender.allViewFirstTitleIndexes[ixChapter];
if (ixTitle != null) $(`[data-title-index="${ixTitle}"]`)[0].scrollIntoView();
return;
}
document.getElementById(`pagecontent`).scrollIntoView();
}
static sectToggle (evt, $btnToggleExpand, $headersBlock) {
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
$btnToggleExpand.text($btnToggleExpand.text() === `[+]` ? `[\u2012]` : `[+]`);
$headersBlock.toggleVe();
}
static _showBookContent (data, fromIndex, bookId, hashParts) {
const ixChapterPrev = BookUtil.curRender.chapter;
const bookIdPrev = BookUtil.curRender.curBookId;
let ixChapter = 0;
let scrollToHeaderText;
let scrollToHeaderNumber;
let isForceScroll = false;
if (hashParts && hashParts.length > 0) ixChapter = Number(hashParts[0]);
const isRenderingNewChapterOrNewBook = BookUtil.curRender.chapter !== ixChapter
|| UrlUtil.encodeForHash(bookIdPrev.toLowerCase()) !== UrlUtil.encodeForHash(bookId);
if (hashParts && hashParts.length > 1) {
scrollToHeaderText = decodeURIComponent(hashParts[1]);
isForceScroll = true;
if (hashParts[2]) {
scrollToHeaderNumber = Number(hashParts[2]);
}
if (BookUtil.referenceId && !isRenderingNewChapterOrNewBook) {
const isHandledScroll = this._showBookContent_handleQuickReferenceShow({sectionHeader: scrollToHeaderText});
if (isHandledScroll) {
isForceScroll = false;
scrollToHeaderText = null;
}
}
} else if (BookUtil.referenceId) {
this._showBookContent_handleQuickReferenceShowAll();
}
BookUtil.curRender.data = data;
BookUtil.curRender.fromIndex = fromIndex;
BookUtil.curRender.headerMap = Renderer.adventureBook.getEntryIdLookup(data);
// If it's a new chapter or a new book
if (isRenderingNewChapterOrNewBook) {
BookUtil.curRender.curBookId = bookId;
BookUtil.curRender.chapter = ixChapter;
BookUtil.$dispBook.html("");
const chapterTitle = (fromIndex.contents[ixChapter] || {}).name;
document.title = `${chapterTitle ? `${chapterTitle} - ` : ""}${fromIndex.name} - 5etools`;
BookUtil.curRender.controls = {};
BookUtil.$dispBook.append(Renderer.utils.getBorderTr());
this._showBookContent_renderNavButtons({isTop: true, ixChapter, bookId, data});
const textStack = [];
BookUtil._renderer
.setFirstSection(true)
.setLazyImages(true)
.resetHeaderIndex()
.setHeaderIndexTableCaptions(true)
.setHeaderIndexImageTitles(true);
if (ixChapter === -1) {
BookUtil.curRender.allViewFirstTitleIndexes = [];
data.forEach(d => {
BookUtil.curRender.allViewFirstTitleIndexes.push(BookUtil._renderer.getHeaderIndex());
BookUtil._renderer.recursiveRender(d, textStack);
});
} else BookUtil._renderer.recursiveRender(data[ixChapter], textStack);
// If there is no source, we're probably in the Quick Reference, so avoid adding the "Excluded" text, as this is a composite source.
BookUtil.$dispBook.append(`
`);
const $srch = $(`
`)
.on("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter" && EventUtil.noModifierKeys(e)) {
const term = $srch.val();
if (isPageMode) {
if (!/^\d+$/.exec(term.trim())) {
return JqueryUtil.doToast({
content: `Please enter a valid page number.`,
type: "danger",
});
}
}
$results.html("");
const found = BookUtil.Search.doSearch(term, isPageMode);
if (found.length) {
$results.show();
found.forEach(f => {
const $row = $(`
`);
const $ptLink = $(`
`);
const isLitTitle = f.headerMatches && !f.page;
const $link = $(
`
${Parser.bookOrdinalToAbv(indexData.contents[f.ch].ordinal)} ${indexData.contents[f.ch].name}${f.header ? ` \u2013 ${isLitTitle ? `` : ""}${f.header}${isLitTitle ? ` ` : ""}` : ""}
`,
).click(evt => BookUtil._handleCheckReNav(evt));
$ptLink.append($link);
$row.append($ptLink);
if (!isPageMode && f.previews) {
const $ptPreviews = $(`
`);
const re = new RegExp(f.term.escapeRegexp(), "gi");
$ptPreviews.on("click", evt => {
BookUtil._handleCheckReNav(evt);
setTimeout(() => {
if (BookUtil._lastHighlight === null || BookUtil._lastHighlight !== f.term.toLowerCase()) {
BookUtil._lastHighlight = f.term;
$(`#pagecontent`)
.find(`p:containsInsensitive("${f.term}"), li:containsInsensitive("${f.term}"), td:containsInsensitive("${f.term}"), a:containsInsensitive("${f.term}")`)
.each((i, ele) => {
$(ele).html($(ele).html().replace(re, "
$& "));
});
}
}, 15);
});
$ptPreviews.append(`
${f.previews[0]} `);
if (f.previews[1]) {
$ptPreviews.append(" ... ");
$ptPreviews.append(`
${f.previews[1]} `);
}
$row.append($ptPreviews);
$link.on("click", () => $ptPreviews.click());
} else {
if (f.page) {
const $ptPage = $(`
Page ${f.page} `);
$row.append($ptPage);
}
}
$results.append($row);
});
} else {
$results.hide();
}
} else if (e.key === "Escape" && EventUtil.noModifierKeys(e)) {
BookUtil._$findAll.remove();
}
});
BookUtil._$findAll.append($srch).append($results);
$(document.body).append(BookUtil._$findAll);
$srch.focus();
}
static _$getRenderedContents (options) {
const book = options.book;
BookUtil.curRender.$btnsToggleExpand = [];
BookUtil.curRender.$lnksChapter = [];
BookUtil.curRender.$lnksHeader = {};
BookUtil.curRender.$btnToggleExpandAll = $(`
${BookUtil.isDefaultExpandedContents ? `[\u2012]` : `[+]`} `)
.click(() => {
const isExpanded = BookUtil.curRender.$btnToggleExpandAll.text() !== `[+]`;
BookUtil.curRender.$btnToggleExpandAll.text(isExpanded ? `[+]` : `[\u2012]`).title(isExpanded ? `Collapse All` : `Expand All`);
BookUtil.curRender.$btnsToggleExpand.forEach($btn => {
if (!$btn) return;
if ($btn.text() !== BookUtil.curRender.$btnToggleExpandAll.text()) $btn.click();
});
});
const $eles = [];
options.book.contents.map((chapter, ixChapter) => {
const $btnToggleExpand = !chapter.headers ? null : $(`
[\u2012] `)
.click(evt => {
BookUtil.sectToggle(evt, $btnToggleExpand, $chapterBlock);
});
BookUtil.curRender.$btnsToggleExpand.push($btnToggleExpand);
const $lnk = $$`
${Parser.bookOrdinalToAbv(chapter.ordinal)}${chapter.name}
${$btnToggleExpand}
`
.click(() => BookUtil.scrollPageTop(ixChapter));
BookUtil.curRender.$lnksChapter.push($lnk);
const $header = $$`
${$lnk}
`;
$eles.push($header);
const $chapterBlock = BookUtil.$getContentsChapterBlock(options.book.id, ixChapter, chapter, options.addPrefix);
$eles.push($chapterBlock);
if (!BookUtil.isDefaultExpandedContents && $btnToggleExpand) BookUtil.sectToggle(null, $btnToggleExpand, $chapterBlock);
});
return $$`
`;
}
static getContentsSectionHeader (header) {
// handle entries with depth
if (header.depth) return `
\u2013 ${header.header}`;
if (header.header) return header.header;
return header;
}
static $getContentsChapterBlock (bookId, ixChapter, chapter, addPrefix) {
const headerCounts = {};
const $eles = [];
chapter.headers && chapter.headers.forEach(h => {
const headerText = BookUtil.getHeaderText(h);
const headerTextClean = headerText.toLowerCase().trim();
const headerPos = headerCounts[headerTextClean] || 0;
headerCounts[headerTextClean] = (headerCounts[headerTextClean] || 0) + 1;
// (Prefer the user-specified `h.index` over the auto-calculated headerPos)
const headerIndex = h.index ?? headerPos;
const displayText = this.getContentsSectionHeader(h);
const $lnk = $$`
0 ? `,${headerIndex}` : ""}" data-book="${bookId}" data-chapter="${ixChapter}" data-header="${headerText.escapeQuotes()}" class="lst__row lst--border lst__row-inner lst__wrp-cells">${displayText} `
.click(() => {
BookUtil.scrollClick(ixChapter, headerText, headerIndex);
});
const $ele = $$`
${$lnk}
`;
$eles.push($ele);
(this.curRender.$lnksHeader[ixChapter] = this.curRender.$lnksHeader[ixChapter] || []).push($lnk);
});
const $out = $$`
${$eles}
`;
MiscUtil.set(BookUtil._$CACHE_HEADER_BLOCKS, bookId, ixChapter, $out);
return $out;
}
static _handleCheckReNav (evt) {
const lnk = evt.currentTarget;
let $lnk = $(lnk);
while ($lnk.length && !$lnk.is("a")) $lnk = $lnk.parent();
BookUtil._$LAST_CLICKED_LINK = $lnk[0];
if (`#${$lnk.attr("href").split("#")[1] || ""}` === window.location.hash) BookUtil.handleReNav(lnk);
}
static _getHrefShowAll (bookId) { return `#${UrlUtil.encodeForHash(bookId)},-1`; }
}
// region Last render/etc
BookUtil.curRender = {
curBookId: "NONE",
chapter: null, // -1 represents "entire book"
data: {},
fromIndex: {},
lastRefHeader: null,
controls: {},
headerMap: {},
allViewFirstTitleIndexes: [], // A list of "data-title-index"s for the first rendered title in each rendered chapter
$btnToggleExpandAll: null,
$btnsToggleExpand: [],
$lnksChapter: [],
$lnksHeader: {},
};
BookUtil._$LAST_CLICKED_LINK = null;
BookUtil._isNarrow = null;
BookUtil._$CACHE_HEADER_BLOCKS = {};
// endregion
// region Hashchange
BookUtil.baseDataUrl = "";
BookUtil.bookIndex = [];
BookUtil.bookIndexPrerelease = [];
BookUtil.bookIndexBrew = [];
BookUtil.propHomebrewData = null;
BookUtil.typeTitle = null;
BookUtil.$dispBook = null;
BookUtil.referenceId = false;
BookUtil.isHashReload = false;
BookUtil.contentType = null; // one of "book" "adventure" or "document"
BookUtil.$wrpFloatControls = null;
// endregion
BookUtil._pendingPageFromHash = null;
BookUtil._renderer = new Renderer().setEnumerateTitlesRel(true).setTrackTitles(true);
BookUtil._$findAll = null;
BookUtil._headerCounts = null;
BookUtil._lastHighlight = null;
BookUtil.Search = class {
static getResultHash (bookId, found) {
return `${UrlUtil.encodeForHash(bookId)}${HASH_PART_SEP}${~BookUtil.curRender.chapter ? found.ch : -1}${found.header ? `${HASH_PART_SEP}${UrlUtil.encodeForHash(found.header)}${HASH_PART_SEP}${found.headerIndex}` : ""}`;
}
static doSearch (term, isPageMode) {
if (term == null) return;
term = term.trim();
if (isPageMode) {
if (isNaN(term)) return [];
else term = Number(term);
} else {
if (!term) return [];
}
const out = [];
const toSearch = BookUtil.curRender.data;
toSearch.forEach((section, i) => {
BookUtil._headerCounts = {};
BookUtil.Search._searchEntriesFor(i, out, term, section, isPageMode);
});
// If we're in page mode, try hard to identify _something_ to display
if (isPageMode && !out.length) {
const [closestPrevPage, closestNextPage] = BookUtil.Search._getClosestPages(toSearch, term);
toSearch.forEach((section, i) => {
BookUtil.Search._searchEntriesFor(i, out, closestPrevPage, section, true);
if (closestNextPage !== closestPrevPage) BookUtil.Search._searchEntriesFor(i, out, closestNextPage, section, true);
});
}
return out;
}
static _searchEntriesFor (chapterIndex, appendTo, term, obj, isPageMode) {
BookUtil._headerCounts = {};
const cleanTerm = isPageMode ? term : term.toLowerCase();
BookUtil.Search._searchEntriesForRecursive(chapterIndex, "", appendTo, term, cleanTerm, obj, isPageMode);
}
static _searchEntriesForRecursive (chapterIndex, prevLastName, appendTo, term, cleanTerm, obj, isPageMode) {
if (BookUtil.Search._isNamedEntry(obj)) {
const cleanName = Renderer.stripTags(obj.name);
if (BookUtil._headerCounts[cleanName] === undefined) BookUtil._headerCounts[cleanName] = 0;
else BookUtil._headerCounts[cleanName]++;
}
let lastName;
if (BookUtil.Search._isNamedEntry(obj)) {
lastName = Renderer.stripTags(obj.name);
const matches = isPageMode ? obj.page === cleanTerm : lastName.toLowerCase().includes(cleanTerm);
if (matches) {
appendTo.push({
ch: chapterIndex,
header: lastName,
headerIndex: BookUtil._headerCounts[lastName],
term,
headerMatches: true,
page: obj.page,
});
}
} else {
lastName = prevLastName;
}
if (obj.entries) {
obj.entries.forEach(e => BookUtil.Search._searchEntriesForRecursive(chapterIndex, lastName, appendTo, term, cleanTerm, e, isPageMode));
} else if (obj.items) {
obj.items.forEach(e => BookUtil.Search._searchEntriesForRecursive(chapterIndex, lastName, appendTo, term, cleanTerm, e, isPageMode));
} else if (obj.rows) {
obj.rows.forEach(r => {
const toSearch = r.row ? r.row : r;
toSearch.forEach(c => BookUtil.Search._searchEntriesForRecursive(chapterIndex, lastName, appendTo, term, cleanTerm, c, isPageMode));
});
} else if (obj.tables) {
obj.tables.forEach(t => BookUtil.Search._searchEntriesForRecursive(chapterIndex, lastName, appendTo, term, cleanTerm, t, isPageMode));
} else if (obj.entry) {
BookUtil.Search._searchEntriesForRecursive(chapterIndex, lastName, appendTo, term, cleanTerm, obj.entry, isPageMode);
} else if (typeof obj === "string" || typeof obj === "number") {
if (isPageMode) return;
const renderStack = [];
BookUtil._renderer.recursiveRender(obj, renderStack);
const rendered = $(`
${renderStack.join("")}
`).text();
const toCheck = typeof obj === "number" ? String(rendered) : rendered.toLowerCase();
if (toCheck.includes(cleanTerm)) {
if (!appendTo.length || (!(appendTo[appendTo.length - 1].header === lastName && appendTo[appendTo.length - 1].headerIndex === BookUtil._headerCounts[lastName] && appendTo[appendTo.length - 1].previews))) {
const first = toCheck.indexOf(cleanTerm);
const last = toCheck.lastIndexOf(cleanTerm);
const slices = [];
if (first === last) {
slices.push(getSubstring(rendered, first, first));
} else {
slices.push(getSubstring(rendered, first, first + `${cleanTerm}`.length));
slices.push(getSubstring(rendered, last, last + `${cleanTerm}`.length));
}
appendTo.push({
ch: chapterIndex,
header: lastName,
headerIndex: BookUtil._headerCounts[lastName],
previews: slices.map(s => s.preview),
term: term,
matches: slices.map(s => s.match),
headerMatches: lastName.toLowerCase().includes(cleanTerm),
});
} else {
const last = toCheck.lastIndexOf(cleanTerm);
const slice = getSubstring(rendered, last, last + `${cleanTerm}`.length);
const lastItem = appendTo[appendTo.length - 1];
lastItem.previews[1] = slice.preview;
lastItem.matches[1] = slice.match;
}
}
}
function getSubstring (rendered, first, last) {
let spaceCount = 0;
let braceCount = 0;
let pre = "";
let i = first - 1;
for (; i >= 0; --i) {
pre = rendered.charAt(i) + pre;
if (rendered.charAt(i) === " " && braceCount === 0) {
spaceCount++;
}
if (spaceCount > BookUtil.Search._EXTRA_WORDS) {
break;
}
}
pre = pre.trimStart();
const preDots = i > 0;
spaceCount = 0;
let post = "";
const start = first === last ? last + `${cleanTerm}`.length : last;
i = Math.min(start, rendered.length);
for (; i < rendered.length; ++i) {
post += rendered.charAt(i);
if (rendered.charAt(i) === " " && braceCount === 0) {
spaceCount++;
}
if (spaceCount > BookUtil.Search._EXTRA_WORDS) {
break;
}
}
post = post.trimEnd();
const postDots = i < rendered.length;
const originalTerm = rendered.substr(first, `${term}`.length);
return {
preview: `${preDots ? "..." : ""}${pre}
${originalTerm} ${post}${postDots ? "..." : ""}`,
match: `${pre}${term}${post}`,
};
}
}
static _isNamedEntry (obj) {
return obj.name && (obj.type === "entries" || obj.type === "inset" || obj.type === "section");
}
static _getClosestPages (toSearch, targetPage) {
let closestBelow = Number.MIN_SAFE_INTEGER;
let closestAbove = Number.MAX_SAFE_INTEGER;
const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
walker.walk(
toSearch,
{
object: (obj) => {
if (obj.page) {
if (obj.page <= targetPage) closestBelow = Math.max(closestBelow, obj.page);
if (obj.page >= targetPage) closestAbove = Math.min(closestAbove, obj.page);
}
return obj;
},
},
);
return [closestBelow, closestAbove];
}
};
BookUtil.Search._EXTRA_WORDS = 2;
globalThis.BookUtil = BookUtil;
if (typeof window !== "undefined") {
window.addEventListener("load", () => $("body").on("click", "a", (evt) => {
BookUtil._handleCheckReNav(evt);
}));
}