Files
5etools-mirror-2.github.io/js/list2.js
TheGiddyLimit 8117ebddc5 v1.198.1
2024-01-01 19:34:49 +00:00

619 lines
17 KiB
JavaScript

"use strict";
class ListItem {
/**
* @param ix External ID information (e.g. the location of the entry this ListItem represents in a list of entries)
* @param ele An element, or jQuery element if the list is in jQuery mode.
* @param name A name for this item.
* @param values A dictionary of indexed values for this item.
* @param [data] An optional dictionary of additional data to store with the item (not indexed).
*/
constructor (ix, ele, name, values, data) {
this.ix = ix;
this.ele = ele;
this.name = name;
this.values = values || {};
this.data = data || {};
this.searchText = null;
this.mutRegenSearchText();
this._isSelected = false;
}
mutRegenSearchText () {
let searchText = `${this.name} - `;
for (const k in this.values) {
const v = this.values[k]; // unsafe for performance
if (!v) continue;
searchText += `${v} - `;
}
this.searchText = searchText.toAscii().toLowerCase();
}
set isSelected (val) {
if (this._isSelected === val) return;
this._isSelected = val;
if (this.ele instanceof $) {
if (this._isSelected) this.ele.addClass("list-multi-selected");
else this.ele.removeClass("list-multi-selected");
} else {
if (this._isSelected) this.ele.classList.add("list-multi-selected");
else this.ele.classList.remove("list-multi-selected");
}
}
get isSelected () { return this._isSelected; }
}
class _ListSearch {
#isInterrupted = false;
#term = null;
#fn = null;
#items = null;
constructor ({term, fn, items}) {
this.#term = term;
this.#fn = fn;
this.#items = [...items];
}
interrupt () { this.#isInterrupted = true; }
async pRun () {
const out = [];
for (const item of this.#items) {
if (this.#isInterrupted) break;
if (await this.#fn(item, this.#term)) out.push(item);
}
return {isInterrupted: this.#isInterrupted, searchedItems: out};
}
}
class List {
#activeSearch = null;
/**
* @param [opts] Options object.
* @param [opts.fnSort] Sort function. Should accept `(a, b, o)` where `o` is an options object. Pass `null` to
* disable sorting.
* @param [opts.fnSearch] Search function. Should accept `(li, searchTerm)` where `li` is a list item.
* @param [opts.$iptSearch] Search input.
* @param opts.$wrpList List wrapper.
* @param [opts.isUseJquery] If the list items are using jQuery elements. Significantly slower for large lists.
* @param [opts.sortByInitial] Initial sortBy.
* @param [opts.sortDirInitial] Initial sortDir.
* @param [opts.syntax] A dictionary of search syntax prefixes, each with an item "to display" checker function.
* @param [opts.isFuzzy]
* @param [opts.isSkipSearchKeybindingEnter]
* @param {array} [opts.helpText]
*/
constructor (opts) {
if (opts.fnSearch && opts.isFuzzy) throw new Error(`The options "fnSearch" and "isFuzzy" are mutually incompatible!`);
this._$iptSearch = opts.$iptSearch;
this._$wrpList = opts.$wrpList;
this._fnSort = opts.fnSort === undefined ? SortUtil.listSort : opts.fnSort;
this._fnSearch = opts.fnSearch;
this._syntax = opts.syntax;
this._isFuzzy = !!opts.isFuzzy;
this._isSkipSearchKeybindingEnter = !!opts.isSkipSearchKeybindingEnter;
this._helpText = opts.helpText;
this._items = [];
this._eventHandlers = {};
this._searchTerm = List._DEFAULTS.searchTerm;
this._sortBy = opts.sortByInitial || List._DEFAULTS.sortBy;
this._sortDir = opts.sortDirInitial || List._DEFAULTS.sortDir;
this._sortByInitial = this._sortBy;
this._sortDirInitial = this._sortDir;
this._fnFilter = null;
this._isUseJquery = opts.isUseJquery;
if (this._isFuzzy) this._initFuzzySearch();
this._searchedItems = [];
this._filteredItems = [];
this._sortedItems = [];
this._isInit = false;
this._isDirty = false;
// region selection
this._prevList = null;
this._nextList = null;
this._lastSelection = null;
this._isMultiSelection = false;
// endregion
}
get items () { return this._items; }
get visibleItems () { return this._sortedItems; }
get sortBy () { return this._sortBy; }
get sortDir () { return this._sortDir; }
set nextList (list) { this._nextList = list; }
set prevList (list) { this._prevList = list; }
setFnSearch (fn) {
this._fnSearch = fn;
this._isDirty = true;
}
init () {
if (this._isInit) return;
// This should only be run after all the elements are ready from page load
if (this._$iptSearch) {
UiUtil.bindTypingEnd({$ipt: this._$iptSearch, fnKeyup: () => this.search(this._$iptSearch.val())});
this._searchTerm = List.getCleanSearchTerm(this._$iptSearch.val());
this._init_bindKeydowns();
// region Help text
const helpText = [
...(this._helpText || []),
...Object.values(this._syntax || {})
.filter(({help}) => help)
.map(({help}) => help),
];
if (helpText.length) this._$iptSearch.title(helpText.join(" "));
// endregion
}
this._doSearch();
this._isInit = true;
}
_init_bindKeydowns () {
this._$iptSearch
.on("keydown", evt => {
// Avoid handling the same event multiple times, if there are multiple lists bound to one input
if (evt._List__isHandled) return;
switch (evt.key) {
case "Escape": return this._handleKeydown_escape(evt);
case "Enter": return this._handleKeydown_enter(evt);
}
});
}
_handleKeydown_escape (evt) {
evt._List__isHandled = true;
if (!this._$iptSearch.val()) {
$(document.activeElement).blur();
return;
}
this._$iptSearch.val("");
this.search("");
}
_handleKeydown_enter (evt) {
if (this._isSkipSearchKeybindingEnter) return;
if (IS_VTT) return;
if (!EventUtil.noModifierKeys(evt)) return;
const firstVisibleItem = this.visibleItems[0];
if (!firstVisibleItem) return;
evt._List__isHandled = true;
$(firstVisibleItem.ele).click();
if (firstVisibleItem.values.hash) window.location.hash = firstVisibleItem.values.hash;
}
_initFuzzySearch () {
elasticlunr.clearStopWords();
this._fuzzySearch = elasticlunr(function () {
this.addField("s");
this.setRef("ix");
});
SearchUtil.removeStemmer(this._fuzzySearch);
}
update ({isForce = false} = {}) {
if (!this._isInit || !this._isDirty || isForce) return false;
this._doSearch();
return true;
}
_doSearch () {
this._doSearch_doInterruptExistingSearch();
this._doSearch_doSearchTerm();
this._doSearch_doPostSearchTerm();
}
_doSearch_doInterruptExistingSearch () {
if (!this.#activeSearch) return;
this.#activeSearch.interrupt();
this.#activeSearch = null;
}
_doSearch_doSearchTerm () {
if (this._doSearch_doSearchTerm_preSyntax()) return;
const matchingSyntax = this._doSearch_getMatchingSyntax();
if (matchingSyntax) {
if (this._doSearch_doSearchTerm_syntax(matchingSyntax)) return;
// For async syntax, blank the list for now, and allow the search to "resume" later
this._searchedItems = [];
this._doSearch_doSearchTerm_pSyntax(matchingSyntax)
.then(isContinue => {
if (!isContinue) return;
this._doSearch_doPostSearchTerm();
});
return;
}
if (this._isFuzzy) return this._searchedItems = this._doSearch_doSearchTerm_fuzzy();
if (this._fnSearch) return this._searchedItems = this._items.filter(it => this._fnSearch(it, this._searchTerm));
this._searchedItems = this._items.filter(it => this.constructor.isVisibleDefaultSearch(it, this._searchTerm));
}
_doSearch_doSearchTerm_preSyntax () {
if (!this._searchTerm && !this._fnSearch) {
this._searchedItems = [...this._items];
return true;
}
}
_doSearch_getMatchingSyntax () {
const [command, term] = this._searchTerm.split(/^([a-z]+):/).filter(Boolean);
if (!command || !term || !this._syntax?.[command]) return null;
return {term: this._doSearch_getSyntaxSearchTerm(term), syntax: this._syntax[command]};
}
_doSearch_getSyntaxSearchTerm (term) {
if (!term.startsWith("/") || !term.endsWith("/")) return term;
try {
return new RegExp(term.slice(1, -1));
} catch (ignored) {
return term;
}
}
_doSearch_doSearchTerm_syntax ({term, syntax: {fn, isAsync}}) {
if (isAsync) return false;
this._searchedItems = this._items.filter(it => fn(it, term));
return true;
}
async _doSearch_doSearchTerm_pSyntax ({term, syntax: {fn, isAsync}}) {
if (!isAsync) return false;
this.#activeSearch = new _ListSearch({
term,
fn,
items: this._items,
});
const {isInterrupted, searchedItems} = await this.#activeSearch.pRun();
if (isInterrupted) return false;
this._searchedItems = searchedItems;
return true;
}
static isVisibleDefaultSearch (li, searchTerm) { return li.searchText.includes(searchTerm); }
_doSearch_doSearchTerm_fuzzy () {
const results = this._fuzzySearch
.search(
this._searchTerm,
{
fields: {
s: {expand: true},
},
bool: "AND",
expand: true,
},
);
return results.map(res => this._items[res.doc.ix]);
}
_doSearch_doPostSearchTerm () {
// Never show excluded items
this._searchedItems = this._searchedItems.filter(it => !it.data.isExcluded);
this._doFilter();
}
getFilteredItems ({items = null, fnFilter} = {}) {
items = items || this._searchedItems;
fnFilter = fnFilter || this._fnFilter;
if (!fnFilter) return items;
return items.filter(it => fnFilter(it));
}
_doFilter () {
this._filteredItems = this.getFilteredItems();
this._doSort();
}
getSortedItems ({items = null} = {}) {
items = items || [...this._filteredItems];
const opts = {
sortBy: this._sortBy,
// The sort function should generally ignore this, as we do the reversing here. We expose it in case there
// is specific functionality that requires it.
sortDir: this._sortDir,
};
if (this._fnSort) items.sort((a, b) => this._fnSort(a, b, opts));
if (this._sortDir === "desc") items.reverse();
return items;
}
_doSort () {
this._sortedItems = this.getSortedItems();
this._doRender();
}
_doRender () {
const len = this._sortedItems.length;
if (this._isUseJquery) {
this._$wrpList.children().detach();
for (let i = 0; i < len; ++i) this._$wrpList.append(this._sortedItems[i].ele);
} else {
this._$wrpList[0].innerHTML = "";
const frag = document.createDocumentFragment();
for (let i = 0; i < len; ++i) frag.appendChild(this._sortedItems[i].ele);
this._$wrpList[0].appendChild(frag);
}
this._isDirty = false;
this._trigger("updated");
}
search (searchTerm) {
const nextTerm = List.getCleanSearchTerm(searchTerm);
if (nextTerm === this._searchTerm) return;
this._searchTerm = nextTerm;
return this._doSearch();
}
filter (fnFilter) {
if (this._fnFilter === fnFilter) return;
this._fnFilter = fnFilter;
this._doFilter();
}
sort (sortBy, sortDir) {
if (this._sortBy !== sortBy || this._sortDir !== sortDir) {
this._sortBy = sortBy;
this._sortDir = sortDir;
this._doSort();
}
}
reset () {
if (this._searchTerm !== List._DEFAULTS.searchTerm) {
this._searchTerm = List._DEFAULTS.searchTerm;
return this._doSearch();
} else if (this._sortBy !== this._sortByInitial || this._sortDir !== this._sortDirInitial) {
this._sortBy = this._sortByInitial;
this._sortDir = this._sortDirInitial;
}
}
addItem (listItem) {
this._isDirty = true;
this._items.push(listItem);
if (this._isFuzzy) this._fuzzySearch.addDoc({ix: listItem.ix, s: listItem.searchText});
}
removeItem (listItem) {
const ixItem = this._items.indexOf(listItem);
return this.removeItemByIndex(listItem.ix, ixItem);
}
removeItemByIndex (ix, ixItem) {
ixItem = ixItem ?? this._items.findIndex(it => it.ix === ix);
if (!~ixItem) return;
this._isDirty = true;
const removed = this._items.splice(ixItem, 1);
if (this._isFuzzy) this._fuzzySearch.removeDocByRef(ix);
return removed[0];
}
removeItemBy (valueName, value) {
const ixItem = this._items.findIndex(it => it.values[valueName] === value);
return this.removeItemByIndex(ixItem, ixItem);
}
removeItemByData (dataName, value) {
const ixItem = this._items.findIndex(it => it.data[dataName] === value);
return this.removeItemByIndex(ixItem, ixItem);
}
removeAllItems () {
this._isDirty = true;
this._items = [];
if (this._isFuzzy) this._initFuzzySearch();
}
on (eventName, handler) {
(this._eventHandlers[eventName] = this._eventHandlers[eventName] || []).push(handler);
}
off (eventName, handler) {
if (!this._eventHandlers[eventName]) return false;
const ix = this._eventHandlers[eventName].indexOf(handler);
if (!~ix) return false;
this._eventHandlers[eventName].splice(ix, 1);
return true;
}
_trigger (eventName) { (this._eventHandlers[eventName] || []).forEach(fn => fn()); }
// region hacks
/**
* Allows the current contents of the list wrapper to be converted to list items.
* Useful in situations where, for whatever reason, we can't fill the list after the fact (e.g. when using Foundry's
* template engine).
* Extremely fragile; use with caution.
* @param dataArr Array from which the list was rendered.
* @param opts Options object.
* @param opts.fnGetName Function which gets the name from a dataSource item.
* @param [opts.fnGetValues] Function which gets list values from a dataSource item.
* @param [opts.fnGetData] Function which gets list data from a listItem and dataSource item.
* @param [opts.fnBindListeners] Function which binds event listeners to the list.
*/
doAbsorbItems (dataArr, opts) {
const children = [...this._$wrpList[0].children];
const len = children.length;
if (len !== dataArr.length) throw new Error(`Data source length and list element length did not match!`);
for (let i = 0; i < len; ++i) {
const node = children[i];
const dataItem = dataArr[i];
const listItem = new ListItem(
i,
node,
opts.fnGetName(dataItem),
opts.fnGetValues ? opts.fnGetValues(dataItem) : {},
{},
);
if (opts.fnGetData) listItem.data = opts.fnGetData(listItem, dataItem);
if (opts.fnBindListeners) opts.fnBindListeners(listItem, dataItem);
this.addItem(listItem);
}
}
// endregion
// region selection
doSelect (item, evt) {
if (evt && evt.shiftKey) {
evt.preventDefault(); // Stop a new window from being opened
// Don't update the last selection, as we want to be able to "pivot" the multi-selection off the first selection
if (this._prevList && this._prevList._lastSelection) {
this._prevList._selectFromItemToEnd(this._prevList._lastSelection, true);
this._selectToItemFromStart(item);
} else if (this._nextList && this._nextList._lastSelection) {
this._nextList._selectToItemFromStart(this._nextList._lastSelection, true);
this._selectFromItemToEnd(item);
} else if (this._lastSelection && this.visibleItems.includes(item)) {
this._doSelect_doMulti(item);
} else {
this._doSelect_doSingle(item);
}
} else this._doSelect_doSingle(item);
}
_doSelect_doSingle (item) {
if (this._isMultiSelection) {
this.deselectAll();
if (this._prevList) this._prevList.deselectAll();
if (this._nextList) this._nextList.deselectAll();
} else if (this._lastSelection) this._lastSelection.isSelected = false;
item.isSelected = true;
this._lastSelection = item;
}
_doSelect_doMulti (item) {
this._selectFromItemToItem(this._lastSelection, item);
if (this._prevList && this._prevList._isMultiSelection) {
this._prevList.deselectAll();
}
if (this._nextList && this._nextList._isMultiSelection) {
this._nextList.deselectAll();
}
}
_selectFromItemToEnd (item, isKeepLastSelection = false) {
this.deselectAll(isKeepLastSelection);
this._isMultiSelection = true;
const ixStart = this.visibleItems.indexOf(item);
const len = this.visibleItems.length;
for (let i = ixStart; i < len; ++i) {
this.visibleItems[i].isSelected = true;
}
}
_selectToItemFromStart (item, isKeepLastSelection = false) {
this.deselectAll(isKeepLastSelection);
this._isMultiSelection = true;
const ixEnd = this.visibleItems.indexOf(item);
for (let i = 0; i <= ixEnd; ++i) {
this.visibleItems[i].isSelected = true;
}
}
_selectFromItemToItem (item1, item2) {
this.deselectAll(true);
if (item1 === item2) {
if (this._lastSelection) this._lastSelection.isSelected = false;
item1.isSelected = true;
this._lastSelection = item1;
return;
}
const ix1 = this.visibleItems.indexOf(item1);
const ix2 = this.visibleItems.indexOf(item2);
this._isMultiSelection = true;
const [ixStart, ixEnd] = [ix1, ix2].sort(SortUtil.ascSort);
for (let i = ixStart; i <= ixEnd; ++i) {
this.visibleItems[i].isSelected = true;
}
}
deselectAll (isKeepLastSelection = false) {
if (!isKeepLastSelection) this._lastSelection = null;
this._isMultiSelection = false;
this._items.forEach(it => it.isSelected = false);
}
updateSelected (item) {
if (this.visibleItems.includes(item)) {
if (this._isMultiSelection) this.deselectAll(true);
if (this._lastSelection && this._lastSelection !== item) this._lastSelection.isSelected = false;
item.isSelected = true;
this._lastSelection = item;
} else this.deselectAll();
}
getSelected () {
return this.visibleItems.filter(it => it.isSelected);
}
// endregion
static getCleanSearchTerm (str) {
return (str || "").toAscii().trim().toLowerCase().split(/\s+/g).join(" ");
}
}
List._DEFAULTS = {
searchTerm: "",
sortBy: "name",
sortDir: "asc",
fnFilter: null,
};
globalThis.List = List;
globalThis.ListItem = ListItem;