mirror of
https://github.com/AUTOMATIC1111/stable-diffusion-webui.git
synced 2026-02-01 13:22:04 -08:00
Merge cd16f56b1f into fd68e0c384
This commit is contained in:
commit
68579b9f78
34 changed files with 8027 additions and 1629 deletions
622
javascript/clusterize.js
Normal file
622
javascript/clusterize.js
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
/* eslint-disable */
|
||||
/*
|
||||
Heavily modified Clusterize.js v1.0.0.
|
||||
Original: http://NeXTs.github.com/Clusterize.js/
|
||||
|
||||
This has been modified to allow for an asynchronous data loader implementation.
|
||||
This differs from the original Clusterize.js which would store the entire dataset
|
||||
in an array and load from that; this caused a large memory overhead in the client.
|
||||
*/
|
||||
|
||||
// Many operations can be lenghty. Try to limit their frequency by debouncing.
|
||||
const SCROLL_DEBOUNCE_TIME_MS = 50;
|
||||
const RESIZE_OBSERVER_DEBOUNCE_TIME_MS = 100; // should be <= refresh debounce time
|
||||
const ELEMENT_OBSERVER_DEBOUNCE_TIME_MS = 100;
|
||||
const REFRESH_DEBOUNCE_TIME_MS = 100;
|
||||
|
||||
class Clusterize {
|
||||
scroll_elem = null;
|
||||
content_elem = null;
|
||||
scroll_id = null;
|
||||
content_id = null;
|
||||
config = {
|
||||
block_height: null,
|
||||
block_width: null,
|
||||
cols_in_block: 1,
|
||||
cluster_height: null,
|
||||
cluster_width: null,
|
||||
is_mac: navigator.userAgent.toLowerCase().indexOf("mac") > -1,
|
||||
item_height: null,
|
||||
item_width: null,
|
||||
max_items: 0,
|
||||
max_rows: 0,
|
||||
rows_in_block: 50,
|
||||
rows_in_cluster: 50 * 5, // default is rows_in_block * blocks_in_cluster
|
||||
};
|
||||
options = {
|
||||
blocks_in_cluster: 5,
|
||||
tag: "div",
|
||||
no_data_class: "clusterize-no-data",
|
||||
no_data_html: "No Data",
|
||||
error_class: "clusterize-error",
|
||||
error_html: "Data Error",
|
||||
keep_parity: true,
|
||||
callbacks: {},
|
||||
};
|
||||
state = {
|
||||
cache: {},
|
||||
curr_cluster: 0,
|
||||
enabled: false,
|
||||
scroll_top: 0,
|
||||
setup_has_run: false,
|
||||
pointer_events_set: false,
|
||||
};
|
||||
#scroll_debounce_timer = 0;
|
||||
#refresh_debounce_timer = null;
|
||||
#resize_observer = null;
|
||||
#resize_observer_timer = null;
|
||||
#element_observer = null;
|
||||
#element_observer_timer = null;
|
||||
#on_scroll_bound;
|
||||
|
||||
constructor(args) {
|
||||
for (const option of Object.keys(this.options)) {
|
||||
if (keyExists(args, option)) {
|
||||
this.options[option] = args[option];
|
||||
}
|
||||
}
|
||||
|
||||
if (isNullOrUndefined(this.options.callbacks.initData)) {
|
||||
this.options.callbacks.initData = this.initDataDefaultCallback.bind(this);
|
||||
}
|
||||
if (isNullOrUndefined(this.options.callbacks.fetchData)) {
|
||||
this.options.callbacks.fetchData = this.fetchDataDefaultCallback.bind(this);
|
||||
}
|
||||
if (isNullOrUndefined(this.options.callbacks.sortData)) {
|
||||
this.options.callbacks.sortData = this.sortDataDefaultCallback.bind(this);
|
||||
}
|
||||
if (isNullOrUndefined(this.options.callbacks.filterData)) {
|
||||
this.options.callbacks.filterData = this.filterDataDefaultCallback.bind(this);
|
||||
}
|
||||
|
||||
this.scroll_elem = args["scrollId"] ? document.getElementById(args["scrollId"]) : args["scrollElem"];
|
||||
isElementThrowError(this.scroll_elem);
|
||||
this.scroll_id = this.scroll_elem.id;
|
||||
|
||||
this.content_elem = args["contentId"] ? document.getElementById(args["contentId"]) : args["contentElem"];
|
||||
isElementThrowError(this.content_elem);
|
||||
this.content_id = this.content_elem.id;
|
||||
|
||||
if (!this.content_elem.hasAttribute("tabindex")) {
|
||||
this.content_elem.setAttribute("tabindex", 0);
|
||||
}
|
||||
|
||||
this.state.scroll_top = this.scroll_elem.scrollTop;
|
||||
|
||||
this.config.max_items = args.max_items;
|
||||
|
||||
this.#on_scroll_bound = this.#onScroll.bind(this);
|
||||
}
|
||||
|
||||
// ==== PUBLIC FUNCTIONS ====
|
||||
enable(state) {
|
||||
// if no state is passed, we enable by default.
|
||||
this.state.enabled = state !== false;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
if (this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fixElementReferences();
|
||||
|
||||
// We use margins to control the scrollbar's size and float our content.
|
||||
this.content_elem.style.marginTop = "0px";
|
||||
this.content_elem.style.marginBottom = "0px";
|
||||
|
||||
await this.#insertToDOM();
|
||||
|
||||
this.#setupEvent("scroll", this.scroll_elem, this.#on_scroll_bound);
|
||||
this.#setupElementObservers();
|
||||
this.#setupResizeObservers();
|
||||
|
||||
this.state.setup_has_run = true;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#html(this.#generateEmptyRow().join(""));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#teardownEvent("scroll", this.scroll_elem, this.#on_scroll_bound);
|
||||
this.#teardownElementObservers();
|
||||
this.#teardownResizeObservers();
|
||||
|
||||
this.#html(this.#generateEmptyRow().join(""));
|
||||
|
||||
this.state.setup_has_run = false;
|
||||
}
|
||||
|
||||
async refresh(force) {
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh can be a longer operation so we want to debounce it to
|
||||
// avoid refreshing too often.
|
||||
clearTimeout(this.#refresh_debounce_timer);
|
||||
this.#refresh_debounce_timer = setTimeout(
|
||||
async () => {
|
||||
if (!isElement(this.content_elem.offsetParent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#recalculateDims() || force) {
|
||||
await this.update()
|
||||
}
|
||||
},
|
||||
REFRESH_DEBOUNCE_TIME_MS,
|
||||
)
|
||||
}
|
||||
|
||||
async update() {
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.scroll_top = this.scroll_elem.scrollTop;
|
||||
// fixes #39
|
||||
if (this.config.max_rows * this.config.item_height < this.state.scroll_top) {
|
||||
this.scroll_elem.scrollTop = 0;
|
||||
this.state.curr_cluster = 0;
|
||||
}
|
||||
|
||||
await this.#insertToDOM();
|
||||
}
|
||||
|
||||
getRowsAmount() {
|
||||
return this.config.max_rows;
|
||||
}
|
||||
|
||||
getScrollProgress() {
|
||||
return this.state.scroll_top / (this.config.max_rows * this.config.item_height) * 100 || 0;
|
||||
}
|
||||
|
||||
async setMaxItems(max_items) {
|
||||
/** Sets the new max number of items.
|
||||
*
|
||||
* This is used to control the scroll bar's length.
|
||||
*
|
||||
* Returns whether the number of max items changed.
|
||||
*/
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
this.config.max_items = max_items;
|
||||
return this.config.max_items !== max_items;
|
||||
}
|
||||
|
||||
this.config.max_items = max_items;
|
||||
}
|
||||
|
||||
// ==== PRIVATE FUNCTIONS ====
|
||||
initDataDefaultCallback() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
async initData() {
|
||||
if (!this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
return await this.options.callbacks.initData();
|
||||
}
|
||||
|
||||
fetchDataDefaultCallback() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async fetchData(idx_start, idx_end) {
|
||||
if (!this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return await this.options.callbacks.fetchData(idx_start, idx_end);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
sortDataDefaultCallback() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async sortData() {
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fixElementReferences();
|
||||
|
||||
// Sort is applied to the filtered data.
|
||||
await this.options.callbacks.sortData();
|
||||
this.#recalculateDims();
|
||||
await this.#insertToDOM();
|
||||
}
|
||||
|
||||
filterDataDefaultCallback() {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
async filterData() {
|
||||
if (!this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter is applied to entire dataset.
|
||||
const max_items = await this.options.callbacks.filterData();
|
||||
await this.setMaxItems(max_items);
|
||||
await this.refresh(true);
|
||||
await this.sortData();
|
||||
}
|
||||
|
||||
#exploreEnvironment(rows, cache) {
|
||||
this.options.content_tag = this.content_elem.tagName.toLowerCase();
|
||||
if (isNullOrUndefined(rows) || !rows.length) {
|
||||
return;
|
||||
}
|
||||
// Temporarily add one row so that we can calculate row dimensions.
|
||||
if (this.content_elem.children.length <= 1) {
|
||||
cache.data = this.#html(rows[0]);
|
||||
}
|
||||
if (!this.options.tag) {
|
||||
this.options.tag = this.content_elem.children[0].tagName.toLowerCase();
|
||||
}
|
||||
this.#recalculateDims();
|
||||
}
|
||||
|
||||
#recalculateDims() {
|
||||
const prev_config = JSON.stringify(this.config);
|
||||
|
||||
this.config.block_height = 0;
|
||||
this.config.block_width = 0;
|
||||
this.config.cluster_height = 0;
|
||||
this.config.cluster_width = 0;
|
||||
this.config.item_height = 0;
|
||||
this.config.item_width = 0;
|
||||
|
||||
if (!this.config.max_items) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first element that isn't one of our placeholder rows.
|
||||
const node = this.content_elem.querySelector(
|
||||
`:scope > :not(.clusterize-extra-row,.${this.options.no_data_class})`
|
||||
);
|
||||
if (!isElement(node)) {
|
||||
// dont attempt to compute dims if we have no data.
|
||||
return;
|
||||
}
|
||||
|
||||
const node_dims = getComputedDims(node);
|
||||
this.config.item_height = node_dims.height;
|
||||
this.config.item_width = node_dims.width;
|
||||
|
||||
// consider table's browser spacing
|
||||
if (this.options.tag === "tr" && getComputedProperty(this.content_elem, "borderCollapse") !== "collapse") {
|
||||
const spacing = parseInt(getComputedProperty(this.content_elem, "borderSpacing"), 10) || 0;
|
||||
this.config.item_height += spacing;
|
||||
this.config.item_width += spacing;
|
||||
}
|
||||
|
||||
// Update rows in block to match the number of elements that can fit in the view.
|
||||
const content_padding = getComputedPaddingDims(this.content_elem);
|
||||
const column_gap = parseFloat(getComputedProperty(this.content_elem, "column-gap"));
|
||||
const row_gap = parseFloat(getComputedProperty(this.content_elem, "row-gap"));
|
||||
if (isNumber(column_gap)) {
|
||||
this.config.item_width += column_gap;
|
||||
}
|
||||
if (isNumber(row_gap)) {
|
||||
this.config.item_height += row_gap;
|
||||
}
|
||||
|
||||
const inner_width = this.scroll_elem.clientWidth - content_padding.width;
|
||||
const inner_height = this.scroll_elem.clientHeight - content_padding.height;
|
||||
// Since we don't allow horizontal scrolling, we want to round down for columns.
|
||||
const cols_in_block = Math.floor(inner_width / this.config.item_width);
|
||||
// Round up for rows so that we don't cut rows off from the view.
|
||||
const rows_in_block = Math.ceil(inner_height / this.config.item_height);
|
||||
|
||||
// Always need at least 1 row/col in block
|
||||
this.config.cols_in_block = Math.max(1, cols_in_block);
|
||||
this.config.rows_in_block = Math.max(1, rows_in_block);
|
||||
|
||||
this.config.block_height = this.config.item_height * this.config.rows_in_block;
|
||||
this.config.block_width = this.config.item_width * this.config.cols_in_block;
|
||||
this.config.rows_in_cluster = this.options.blocks_in_cluster * this.config.rows_in_block;
|
||||
this.config.cluster_height = this.options.blocks_in_cluster * this.config.block_height;
|
||||
this.config.cluster_width = this.config.block_width;
|
||||
|
||||
this.config.max_rows = Math.ceil(this.config.max_items / this.config.cols_in_block);
|
||||
this.config.max_clusters = Math.ceil(this.config.max_rows / this.config.rows_in_cluster);
|
||||
|
||||
return prev_config !== JSON.stringify(this.config);
|
||||
}
|
||||
|
||||
#generateEmptyRow({is_error}={}) {
|
||||
const row = document.createElement(is_error ? "div" : this.options.tag);
|
||||
row.className = is_error ? this.options.error_class : this.options.no_data_class;
|
||||
if (this.options.tag === "tr") {
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = 100;
|
||||
td.innerHTML = is_error ? this.options.error_html : this.options.no_data_html;
|
||||
row.appendChild(td);
|
||||
} else {
|
||||
row.innerHTML = is_error ? this.options.error_html : this.options.no_data_html;
|
||||
}
|
||||
return [row.outerHTML];
|
||||
}
|
||||
|
||||
#getClusterNum() {
|
||||
this.state.scroll_top = this.scroll_elem.scrollTop;
|
||||
const cluster_divider = this.config.cluster_height - this.config.block_height;
|
||||
const current_cluster = Math.floor(this.state.scroll_top / cluster_divider);
|
||||
return Math.min(current_cluster, this.config.max_clusters);
|
||||
}
|
||||
|
||||
async #generate() {
|
||||
const rows_start = Math.max(0, (this.config.rows_in_cluster - this.config.rows_in_block) * this.#getClusterNum());
|
||||
const rows_end = rows_start + this.config.rows_in_cluster;
|
||||
const top_offset = Math.max(0, rows_start * this.config.item_height);
|
||||
const bottom_offset = Math.max(0, (this.config.max_rows - rows_end) * this.config.item_height);
|
||||
const rows_above = top_offset < 1 ? rows_start + 1 : rows_start;
|
||||
|
||||
const idx_start = Math.max(0, rows_start * this.config.cols_in_block);
|
||||
const idx_end = Math.min(this.config.max_items, rows_end * this.config.cols_in_block);
|
||||
|
||||
let this_cluster_rows = await this.fetchData(idx_start, idx_end);
|
||||
if (!Array.isArray(this_cluster_rows) || !this_cluster_rows.length) {
|
||||
console.error(`Failed to fetch data for idx range (${idx_start},${idx_end})`);
|
||||
this_cluster_rows = [];
|
||||
}
|
||||
|
||||
if (this_cluster_rows.length < this.config.rows_in_block) {
|
||||
return {
|
||||
top_offset: 0,
|
||||
bottom_offset: 0,
|
||||
rows_above: 0,
|
||||
rows: this_cluster_rows.length ? this_cluster_rows : this.#generateEmptyRow({is_error: true}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
top_offset: top_offset,
|
||||
bottom_offset: bottom_offset,
|
||||
rows_above: rows_above,
|
||||
rows: this_cluster_rows,
|
||||
};
|
||||
}
|
||||
|
||||
async #insertToDOM() {
|
||||
if (!this.config.cluster_height || !this.config.cluster_width) {
|
||||
// We need to fetch a single item so that we can calculate the dimensions
|
||||
// for our list.
|
||||
const rows = await this.fetchData(0, 1);
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
// This implies there is no data for this list. Not an error.
|
||||
// Errors should be handled in the fetchData callback, not here.
|
||||
this.#html(this.#generateEmptyRow().join(""));
|
||||
return;
|
||||
} else {
|
||||
this.#html(rows.join(""));
|
||||
this.#exploreEnvironment(rows, this.state.cache);
|
||||
// Remove the temporary item from the data since we calculated its size.
|
||||
this.#html(this.#generateEmptyRow().join(""));
|
||||
}
|
||||
}
|
||||
|
||||
const data = await this.#generate();
|
||||
let this_cluster_rows = [];
|
||||
for (let i = 0; i < data.rows.length; i += this.config.cols_in_block) {
|
||||
const new_row = data.rows.slice(i, i + this.config.cols_in_block).join("");
|
||||
this_cluster_rows.push(new_row);
|
||||
}
|
||||
this_cluster_rows = this_cluster_rows.join("");
|
||||
const this_cluster_content_changed = this.#checkChanges("data", this_cluster_rows, this.state.cache);
|
||||
const top_offset_changed = this.#checkChanges("top", data.top_offset, this.state.cache);
|
||||
const only_bottom_offset_changed = this.#checkChanges("bottom", data.bottom_offset, this.state.cache);
|
||||
const layout = [];
|
||||
|
||||
if (this_cluster_content_changed || top_offset_changed) {
|
||||
if (this.options.callbacks.clusterWillChange) {
|
||||
this.options.callbacks.clusterWillChange();
|
||||
}
|
||||
|
||||
if (data.top_offset && this.options.keep_parity) {
|
||||
layout.push(this.#renderExtraTag("keep-parity"));
|
||||
}
|
||||
layout.push(this_cluster_rows)
|
||||
this.#html(layout.join(""));
|
||||
if (this.options.content_tag === "ol") {
|
||||
this.content_elem.setAttribute("start", data.rows_above);
|
||||
}
|
||||
this.content_elem.style["counter-increment"] = `clusterize-counter ${data.rows_above - 1}`;
|
||||
if (this.options.callbacks.clusterChanged) {
|
||||
this.options.callbacks.clusterChanged();
|
||||
}
|
||||
}
|
||||
// Update the margins to fix scrollbar position.
|
||||
this.content_elem.style.marginTop = `${data.top_offset}px`;
|
||||
this.content_elem.style.marginBottom = `${data.bottom_offset}px`;
|
||||
}
|
||||
|
||||
#html(data) {
|
||||
this.content_elem.innerHTML = data;
|
||||
|
||||
// Parse items flagged as containing Shadow DOM entries.
|
||||
convertElementShadowDOM(this.content_elem, "[data-parse-as-shadow-dom]");
|
||||
|
||||
return this.content_elem.innerHTML;
|
||||
}
|
||||
|
||||
#renderExtraTag(class_name, height) {
|
||||
const tag = document.createElement(this.options.tag);
|
||||
const clusterize_prefix = "clusterize-";
|
||||
tag.className = [
|
||||
`${clusterize_prefix}extra-row`,
|
||||
`${clusterize_prefix}${class_name}`,
|
||||
].join(" ");
|
||||
if (isNumber(height)) {
|
||||
tag.style.height = `${height}px`;
|
||||
}
|
||||
return tag.outerHTML;
|
||||
}
|
||||
|
||||
#getChildNodes(tag) {
|
||||
const child_nodes = tag.children;
|
||||
const nodes = [];
|
||||
for (let i = 0, j = child_nodes.length; i < j; i++) {
|
||||
nodes.push(child_nodes[i]);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
#checkChanges(type, value, cache) {
|
||||
const changed = value !== cache[type];
|
||||
cache[type] = value;
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ==== EVENT HANDLERS ====
|
||||
|
||||
async #onScroll() {
|
||||
if (this.config.is_mac) {
|
||||
if (!this.state.pointer_events_set) {
|
||||
this.content_elem.style.pointerEvents = "none";
|
||||
this.state.pointer_events_set = true;
|
||||
clearTimeout(this.#scroll_debounce_timer);
|
||||
this.#scroll_debounce_timer = setTimeout(() => {
|
||||
this.content_elem.style.pointerEvents = "auto";
|
||||
this.state.pointer_events_set = false;
|
||||
}, SCROLL_DEBOUNCE_TIME_MS);
|
||||
}
|
||||
}
|
||||
if (this.state.curr_cluster !== (this.state.curr_cluster = this.#getClusterNum())) {
|
||||
await this.#insertToDOM();
|
||||
}
|
||||
if (this.options.callbacks.scrollingProgress) {
|
||||
this.options.callbacks.scrollingProgress(this.getScrollProgress());
|
||||
}
|
||||
}
|
||||
|
||||
async #onResize() {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
#fixElementReferences() {
|
||||
if (!isElement(this.scroll_elem) || !isElement(this.content_elem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Element is already in DOM. Don't need to do anything.
|
||||
if (isElement(this.content_elem.offsetParent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If association for elements is broken, replace them with instance version.
|
||||
document.getElementById(this.scroll_id).replaceWith(this.scroll_elem);
|
||||
}
|
||||
|
||||
#setupElementObservers() {
|
||||
/** Listens for changes to the scroll and content elements.
|
||||
*
|
||||
* During testing, the scroll/content elements would frequently get removed from
|
||||
* the DOM. This instance stores a reference to these elements
|
||||
* which breaks whenever these elements are removed from the DOM. To fix this,
|
||||
* we need to check for these changes and re-attach our stores elements by
|
||||
* replacing the ones in the DOM with the ones in our clusterize instance.
|
||||
*/
|
||||
|
||||
this.#element_observer = new MutationObserver((mutations) => {
|
||||
const scroll_elem = document.getElementById(this.scroll_id);
|
||||
if (isElement(scroll_elem) && scroll_elem !== this.scroll_elem) {
|
||||
clearTimeout(this.#element_observer_timer);
|
||||
this.#element_observer_timer = setTimeout(
|
||||
this.#fixElementReferences,
|
||||
ELEMENT_OBSERVER_DEBOUNCE_TIME_MS,
|
||||
);
|
||||
}
|
||||
|
||||
const content_elem = document.getElementById(this.content_id);
|
||||
if (isElement(content_elem) && content_elem !== this.content_elem) {
|
||||
clearTimeout(this.#element_observer_timer);
|
||||
this.#element_observer_timer = setTimeout(
|
||||
this.#fixElementReferences,
|
||||
ELEMENT_OBSERVER_DEBOUNCE_TIME_MS,
|
||||
);
|
||||
}
|
||||
});
|
||||
const options = { subtree: true, childList: true, attributes: true };
|
||||
this.#element_observer.observe(document, options);
|
||||
}
|
||||
|
||||
#teardownElementObservers() {
|
||||
if (!isNullOrUndefined(this.#element_observer)) {
|
||||
this.#element_observer.takeRecords();
|
||||
this.#element_observer.disconnect();
|
||||
}
|
||||
this.#element_observer = null;
|
||||
}
|
||||
|
||||
#setupResizeObservers() {
|
||||
/** Handles any updates to the size of both the Scroll and Content elements. */
|
||||
this.#resize_observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target.id === this.scroll_id || entry.target.id === this.content_id) {
|
||||
// debounce the event
|
||||
clearTimeout(this.#resize_observer_timer);
|
||||
this.#resize_observer_timer = setTimeout(
|
||||
() => this.#onResize(),
|
||||
RESIZE_OBSERVER_DEBOUNCE_TIME_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.#resize_observer.observe(this.scroll_elem);
|
||||
this.#resize_observer.observe(this.content_elem);
|
||||
}
|
||||
|
||||
#teardownResizeObservers() {
|
||||
if (!isNullOrUndefined(this.#resize_observer)) {
|
||||
this.#resize_observer.disconnect();
|
||||
}
|
||||
|
||||
if (!isNullOrUndefined(this.#resize_observer_timer)) {
|
||||
clearTimeout(this.#resize_observer_timer);
|
||||
}
|
||||
|
||||
this.#resize_observer = null;
|
||||
this.#resize_observer_timer = null;
|
||||
}
|
||||
|
||||
// ==== HELPER FUNCTIONS ====
|
||||
|
||||
#setupEvent(type, elem, listener) {
|
||||
if (elem.addEventListener) {
|
||||
return elem.addEventListener(type, listener, false);
|
||||
} else {
|
||||
return elem.attachEvent(`on${type}`, listener);
|
||||
}
|
||||
}
|
||||
|
||||
#teardownEvent(type, elem, listener) {
|
||||
if (elem.removeEventListener) {
|
||||
return elem.removeEventListener(type, listener, false);
|
||||
} else {
|
||||
return elem.detachEvent(`on${type}`, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
667
javascript/extraNetworksClusterize.js
Normal file
667
javascript/extraNetworksClusterize.js
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
// Prevent eslint errors on functions defined in other files.
|
||||
/*global
|
||||
Clusterize,
|
||||
getValueThrowError,
|
||||
INT_COLLATOR,
|
||||
STR_COLLATOR,
|
||||
LRUCache,
|
||||
isString,
|
||||
isNullOrUndefined,
|
||||
isNullOrUndefinedLogError,
|
||||
isElement,
|
||||
isElementLogError,
|
||||
keyExistsLogError,
|
||||
htmlStringToElement,
|
||||
convertElementShadowDOM,
|
||||
*/
|
||||
/*eslint no-undef: "error"*/
|
||||
|
||||
// number of list html items to store in cache.
|
||||
const EXTRA_NETWORKS_CLUSTERIZE_LRU_CACHE_SIZE = 1000;
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
constructor(...params) {
|
||||
super(...params);
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, NotImplementedError);
|
||||
}
|
||||
|
||||
this.name = "NotImplementedError";
|
||||
}
|
||||
}
|
||||
|
||||
class ExtraNetworksClusterize extends Clusterize {
|
||||
data_obj = {};
|
||||
data_obj_keys_sorted = [];
|
||||
lru = null;
|
||||
sort_reverse = false;
|
||||
default_sort_fn = this.sortByDivId;
|
||||
sort_fn = this.default_sort_fn;
|
||||
tabname = "";
|
||||
extra_networks_tabname = "";
|
||||
initial_load = false;
|
||||
|
||||
// Override base class defaults
|
||||
default_sort_mode_str = "divId";
|
||||
default_sort_dir_str = "ascending";
|
||||
default_filter_str = "";
|
||||
sort_mode_str = this.default_sort_mode_str;
|
||||
sort_dir_str = this.default_sort_dir_str;
|
||||
filter_str = this.default_filter_str;
|
||||
directory_filters = {};
|
||||
|
||||
constructor(args) {
|
||||
super(args);
|
||||
this.tabname = getValueThrowError(args, "tabname");
|
||||
this.extra_networks_tabname = getValueThrowError(args, "extra_networks_tabname");
|
||||
}
|
||||
|
||||
sortByDivId(data) {
|
||||
/** Sort data_obj keys (div_id) as numbers. */
|
||||
return Object.keys(data).sort(INT_COLLATOR.compare);
|
||||
}
|
||||
|
||||
async reinitData() {
|
||||
await this.initData();
|
||||
// can't use super class' sort since it relies on setup being run first.
|
||||
// but we do need to make sure to sort the new data before continuing.
|
||||
const max_items = Object.keys(this.data_obj).filter(k => this.data_obj[k].visible).length;
|
||||
await this.setMaxItems(max_items);
|
||||
await this.refresh(true);
|
||||
await this.options.callbacks.sortData();
|
||||
}
|
||||
|
||||
async setup() {
|
||||
if (this.state.setup_has_run || !this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lru instanceof LRUCache) {
|
||||
this.lru.clear();
|
||||
} else {
|
||||
this.lru = new LRUCache(EXTRA_NETWORKS_CLUSTERIZE_LRU_CACHE_SIZE);
|
||||
}
|
||||
|
||||
await this.reinitData();
|
||||
|
||||
if (this.state.enabled) {
|
||||
await super.setup();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.initial_load = false;
|
||||
this.data_obj = {};
|
||||
this.data_obj_keys_sorted = [];
|
||||
if (this.lru instanceof LRUCache) {
|
||||
this.lru.destroy();
|
||||
this.lru = null;
|
||||
}
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.initial_load = false;
|
||||
this.data_obj = {};
|
||||
this.data_obj_keys_sorted = [];
|
||||
if (this.lru instanceof LRUCache) {
|
||||
this.lru.clear();
|
||||
}
|
||||
super.clear();
|
||||
}
|
||||
|
||||
async load(force_init_data) {
|
||||
if (!this.state.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initial_load = true;
|
||||
if (!this.state.setup_has_run) {
|
||||
await this.setup();
|
||||
} else if (force_init_data) {
|
||||
await this.reinitData();
|
||||
} else {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
setSortMode(sort_mode_str) {
|
||||
if (this.sort_mode_str === sort_mode_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sort_mode_str = sort_mode_str;
|
||||
this.sortData();
|
||||
}
|
||||
|
||||
setSortDir(sort_dir_str) {
|
||||
const reverse = (sort_dir_str === "descending");
|
||||
if (this.sort_reverse === reverse) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sort_dir_str = sort_dir_str;
|
||||
this.sort_reverse = reverse;
|
||||
this.sortData();
|
||||
}
|
||||
|
||||
setFilterStr(filter_str) {
|
||||
if (isString(filter_str) && this.filter_str !== filter_str) {
|
||||
this.filter_str = filter_str;
|
||||
} else if (isNullOrUndefined(filter_str)) {
|
||||
this.filter_str = this.default_filter_str;
|
||||
}
|
||||
this.filterData();
|
||||
}
|
||||
|
||||
setDirectoryFilters(filters) {
|
||||
if (isNullOrUndefined(filters)) {
|
||||
this.directory_filters = {};
|
||||
return;
|
||||
}
|
||||
this.directory_filters = JSON.parse(JSON.stringify(filters));
|
||||
}
|
||||
|
||||
addDirectoryFilter(div_id, filter_str, recurse) {
|
||||
this.directory_filters[div_id] = {filter_str: filter_str, recurse: recurse};
|
||||
}
|
||||
|
||||
removeDirectoryFilter(div_id) {
|
||||
delete this.directory_filters[div_id];
|
||||
}
|
||||
|
||||
clearDirectoryFilters({excluded_div_ids} = {}) {
|
||||
if (isString(excluded_div_ids)) {
|
||||
excluded_div_ids = [excluded_div_ids];
|
||||
}
|
||||
|
||||
if (!Array.isArray(excluded_div_ids)) {
|
||||
excluded_div_ids = [];
|
||||
}
|
||||
|
||||
for (const div_id of Object.keys(this.directory_filters)) {
|
||||
if (excluded_div_ids.includes(div_id)) {
|
||||
continue;
|
||||
}
|
||||
delete this.directory_filters[div_id];
|
||||
}
|
||||
}
|
||||
|
||||
getDirectoryFilters() {
|
||||
return this.directory_filters;
|
||||
}
|
||||
|
||||
async initDataDefaultCallback() {
|
||||
throw new NotImplementedError();
|
||||
}
|
||||
|
||||
idxRangeToDivIds(idx_start, idx_end) {
|
||||
return this.data_obj_keys_sorted.slice(idx_start, idx_end);
|
||||
}
|
||||
|
||||
async fetchDivIds(div_ids) {
|
||||
if (isNullOrUndefinedLogError(this.lru)) {
|
||||
return [];
|
||||
}
|
||||
if (Object.keys(this.data_obj).length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lru_keys = Array.from(this.lru.cache.keys());
|
||||
const cached_div_ids = div_ids.filter(x => lru_keys.includes(x));
|
||||
const missing_div_ids = div_ids.filter(x => !lru_keys.includes(x));
|
||||
|
||||
const data = {};
|
||||
// Fetch any div IDs not in the LRU Cache using our callback.
|
||||
if (missing_div_ids.length !== 0) {
|
||||
const fetched_data = await this.options.callbacks.fetchData(missing_div_ids);
|
||||
if (Object.keys(fetched_data).length !== missing_div_ids.length) {
|
||||
// expected data. got nothing.
|
||||
return {};
|
||||
}
|
||||
Object.assign(data, fetched_data);
|
||||
}
|
||||
|
||||
// Now load any cached IDs from the LRU Cache
|
||||
for (const div_id of cached_div_ids) {
|
||||
if (!keyExistsLogError(this.data_obj, div_id)) {
|
||||
continue;
|
||||
}
|
||||
if (this.data_obj[div_id].visible) {
|
||||
data[div_id] = this.lru.get(div_id);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async fetchDataDefaultCallback() {
|
||||
throw new NotImplementedError();
|
||||
}
|
||||
|
||||
async sortDataDefaultCallback() {
|
||||
// we want to apply the sort to the visible items only.
|
||||
const filtered = Object.fromEntries(
|
||||
Object.entries(this.data_obj).filter(([k, v]) => v.visible)
|
||||
);
|
||||
this.data_obj_keys_sorted = this.sort_fn(filtered);
|
||||
if (this.sort_reverse) {
|
||||
this.data_obj_keys_sorted = this.data_obj_keys_sorted.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
async filterDataDefaultCallback() {
|
||||
throw new NotImplementedError();
|
||||
}
|
||||
|
||||
updateHtml(elem, new_html) {
|
||||
const existing = this.lru.get(String(elem.dataset.divId));
|
||||
if (new_html) {
|
||||
if (existing === new_html) {
|
||||
return;
|
||||
}
|
||||
const parsed_html = htmlStringToElement(new_html);
|
||||
convertElementShadowDOM(parsed_html, "[data-parse-as-shadow-dom]");
|
||||
|
||||
// replace the element in DOM with our new element
|
||||
elem.replaceWith(parsed_html);
|
||||
|
||||
// update the internal cache with the new html
|
||||
this.lru.set(String(elem.dataset.divId), new_html);
|
||||
} else {
|
||||
if (existing === elem.outerHTML) {
|
||||
return;
|
||||
}
|
||||
this.lru.set(String(elem.dataset.divId), elem.outerHTML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExtraNetworksClusterizeTreeList extends ExtraNetworksClusterize {
|
||||
prev_selected_div_id = null;
|
||||
|
||||
constructor(args) {
|
||||
super({...args});
|
||||
this.selected_div_ids = new Set();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.prev_selected_div_id = null;
|
||||
this.selected_div_ids.clear();
|
||||
super.clear();
|
||||
}
|
||||
|
||||
setRowSelected(elem) {
|
||||
if (!isElement(elem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateHtml(elem);
|
||||
this.selected_div_ids.add(elem.dataset.divId);
|
||||
this.prev_selected_div_id = elem.dataset.divId;
|
||||
}
|
||||
|
||||
setRowDeselected(elem) {
|
||||
if (!isElement(elem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateHtml(elem);
|
||||
this.selected_div_ids.delete(elem.dataset.divId);
|
||||
this.prev_selected_div_id = null;
|
||||
}
|
||||
|
||||
clearSelectedRows({excluded_div_ids} = {}) {
|
||||
if (isString(excluded_div_ids)) {
|
||||
excluded_div_ids = [excluded_div_ids];
|
||||
}
|
||||
|
||||
if (!Array.isArray(excluded_div_ids)) {
|
||||
excluded_div_ids = [];
|
||||
}
|
||||
|
||||
this.selected_div_ids.clear();
|
||||
for (const div_id of excluded_div_ids) {
|
||||
this.selected_div_ids.add(div_id);
|
||||
}
|
||||
if (!excluded_div_ids.includes(this.prev_selected_div_id)) {
|
||||
this.prev_selected_div_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
getMaxRowWidth() {
|
||||
/** Calculates the width of the widest row in the list. */
|
||||
if (!this.state.enabled) {
|
||||
// Inactive list is not displayed on screen. Can't calculate size.
|
||||
return;
|
||||
}
|
||||
if (this.content_elem.children.length === 0) {
|
||||
// If there is no data then just skip.
|
||||
return;
|
||||
}
|
||||
|
||||
let max_width = 0;
|
||||
for (let i = 0; i < this.content_elem.children.length; i += this.config.cols_in_block) {
|
||||
let row_width = 0;
|
||||
for (let j = 0; j < this.config.cols_in_block; j++) {
|
||||
const child = this.content_elem.children[i + j];
|
||||
if (!(child.classList.contains("tree-list-item"))) {
|
||||
continue;
|
||||
}
|
||||
// Child first element is the indent div. Just use offset for this
|
||||
// since we do some overlapping with ::after in CSS.
|
||||
row_width += child.children[0].offsetWidth;
|
||||
// Button is second element. We want entire scroll width of this one.
|
||||
// But first we need to allow it to shrink to content.
|
||||
const prev_css_text = child.children[1].cssText;
|
||||
child.children[1].style.flex = "0 1 auto";
|
||||
row_width += child.children[1].scrollWidth;
|
||||
// Add the button label's overflow to the width.
|
||||
const lbl = child.querySelector(".tree-list-item-label");
|
||||
row_width += lbl.scrollWidth - lbl.offsetWidth;
|
||||
// Revert changes to element style.
|
||||
if (!prev_css_text) {
|
||||
child.children[1].removeAttribute("style");
|
||||
} else {
|
||||
child.children[1].cssText = prev_css_text;
|
||||
}
|
||||
}
|
||||
max_width = Math.max(row_width, max_width);
|
||||
}
|
||||
if (max_width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adds the scroll_elem's scrollbar and padding to the result.
|
||||
// If scrollbar isn't visible, then only the element border/padding is added.
|
||||
max_width += this.scroll_elem.offsetWidth - this.content_elem.offsetWidth;
|
||||
return max_width;
|
||||
}
|
||||
|
||||
async expandAllRows(div_id) {
|
||||
/** Recursively expands all directories below the passed div_id. */
|
||||
if (!keyExistsLogError(this.data_obj, div_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _expand = (parent_id) => {
|
||||
const this_obj = this.data_obj[parent_id];
|
||||
this_obj.visible = true;
|
||||
this_obj.expanded = true;
|
||||
for (const child_id of this_obj.children) {
|
||||
_expand(child_id);
|
||||
}
|
||||
};
|
||||
|
||||
this.data_obj[div_id].expanded = true;
|
||||
for (const child_id of this.data_obj[div_id].children) {
|
||||
_expand(child_id);
|
||||
}
|
||||
|
||||
const new_len = Object.values(this.data_obj).filter(v => v.visible).length;
|
||||
await this.setMaxItems(new_len);
|
||||
await this.refresh(true);
|
||||
await this.sortData();
|
||||
}
|
||||
|
||||
async collapseAllRows(div_id) {
|
||||
/** Recursively collapses all directories below the passed div_id. */
|
||||
if (!keyExistsLogError(this.data_obj, div_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _collapse = (parent_id) => {
|
||||
const this_obj = this.data_obj[parent_id];
|
||||
this_obj.visible = false;
|
||||
this_obj.expanded = false;
|
||||
for (const child_id of this_obj.children) {
|
||||
_collapse(child_id);
|
||||
}
|
||||
};
|
||||
|
||||
this.data_obj[div_id].expanded = false;
|
||||
for (const child_id of this.data_obj[div_id].children) {
|
||||
_collapse(child_id);
|
||||
}
|
||||
|
||||
// Deselect current selected div id if it was just hidden.
|
||||
if (this.selected_div_ids.has(div_id) && !this.data_obj[div_id].visible) {
|
||||
this.selected_div_ids.delete(div_id);
|
||||
if (this.prev_selected_div_id === div_id) {
|
||||
this.prev_selected_div_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
const new_len = Object.values(this.data_obj).filter(v => v.visible).length;
|
||||
await this.setMaxItems(new_len);
|
||||
await this.refresh(true);
|
||||
await this.sortData();
|
||||
}
|
||||
|
||||
getChildrenDivIds(div_id, {recurse} = {}) {
|
||||
const res = JSON.parse(JSON.stringify(this.data_obj[div_id].children));
|
||||
if (recurse === true) {
|
||||
for (const child_id of this.data_obj[div_id].children) {
|
||||
res.push(...this.getChildrenDivIds(child_id, {recurse: recurse}));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async toggleRowExpanded(div_id) {
|
||||
/** Toggles a row between expanded and collapses states. */
|
||||
if (!keyExistsLogError(this.data_obj, div_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle state
|
||||
this.data_obj[div_id].expanded = !this.data_obj[div_id].expanded;
|
||||
|
||||
const _set_visibility = (parent_id, visible) => {
|
||||
const this_obj = this.data_obj[parent_id];
|
||||
this_obj.visible = visible;
|
||||
for (const child_id of this_obj.children) {
|
||||
_set_visibility(child_id, visible && this_obj.expanded);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child_id of this.data_obj[div_id].children) {
|
||||
_set_visibility(child_id, this.data_obj[div_id].expanded);
|
||||
}
|
||||
|
||||
// Deselect current selected div id if it was just hidden.
|
||||
if (this.selected_div_ids.has(div_id) && !this.data_obj[div_id].visible) {
|
||||
this.selected_div_ids.delete(div_id);
|
||||
if (this.prev_selected_div_id === div_id) {
|
||||
this.prev_selected_div_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
const new_len = Object.values(this.data_obj).filter(v => v.visible).length;
|
||||
await this.setMaxItems(new_len);
|
||||
await this.refresh(true);
|
||||
await this.sortData();
|
||||
}
|
||||
|
||||
async initData() {
|
||||
/*Expects an object like the following:
|
||||
{
|
||||
parent: null or div_id,
|
||||
children: array of div_id's,
|
||||
visible: bool,
|
||||
expanded: bool,
|
||||
}
|
||||
*/
|
||||
this.data_obj = await this.options.callbacks.initData();
|
||||
}
|
||||
|
||||
async fetchData(idx_start, idx_end) {
|
||||
if (!this.state.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Object.keys(this.data_obj).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await this.fetchDivIds(this.idxRangeToDivIds(idx_start, idx_end));
|
||||
const data_ids_sorted = Object.keys(data).sort((a, b) => {
|
||||
return this.data_obj_keys_sorted.indexOf(a) - this.data_obj_keys_sorted.indexOf(b);
|
||||
});
|
||||
|
||||
const res = [];
|
||||
for (const div_id of data_ids_sorted) {
|
||||
if (!keyExistsLogError(this.data_obj, div_id)) {
|
||||
continue;
|
||||
}
|
||||
const html_str = data[div_id];
|
||||
const elem = isElement(html_str) ? html_str : htmlStringToElement(html_str);
|
||||
|
||||
// Roots come expanded by default. Need to delete if it exists.
|
||||
delete elem.dataset.expanded;
|
||||
if (this.data_obj[div_id].expanded) {
|
||||
elem.dataset.expanded = "";
|
||||
}
|
||||
|
||||
delete elem.dataset.selected;
|
||||
if (this.selected_div_ids.has(div_id)) {
|
||||
elem.dataset.selected = "";
|
||||
}
|
||||
|
||||
this.lru.set(String(div_id), elem.outerHTML);
|
||||
res.push(elem.outerHTML);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async filterDataDefaultCallback() {
|
||||
// just return the number of visible objects in our data.
|
||||
return Object.values(this.data_obj).filter(v => v.visible).length;
|
||||
}
|
||||
}
|
||||
|
||||
class ExtraNetworksClusterizeCardList extends ExtraNetworksClusterize {
|
||||
constructor(args) {
|
||||
super({...args});
|
||||
}
|
||||
|
||||
sortByPath(data) {
|
||||
return Object.keys(data).sort((a, b) => {
|
||||
return INT_COLLATOR.compare(data[a].sort_path, data[b].sort_path);
|
||||
});
|
||||
}
|
||||
|
||||
sortByName(data) {
|
||||
return Object.keys(data).sort((a, b) => {
|
||||
return INT_COLLATOR.compare(data[a].sort_name, data[b].sort_name);
|
||||
});
|
||||
}
|
||||
|
||||
sortByDateCreated(data) {
|
||||
return Object.keys(data).sort((a, b) => {
|
||||
return INT_COLLATOR.compare(data[a].sort_date_created, data[b].sort_date_created);
|
||||
});
|
||||
}
|
||||
|
||||
sortByDateModified(data) {
|
||||
return Object.keys(data).sort((a, b) => {
|
||||
return INT_COLLATOR.compare(data[a].sort_date_modified, data[b].sort_date_modified);
|
||||
});
|
||||
}
|
||||
|
||||
async initData() {
|
||||
/*Expects an object like the following:
|
||||
{
|
||||
search_keys: array of strings,
|
||||
search_only: bool,
|
||||
sort_<mode>: string, (for various sort modes)
|
||||
}
|
||||
*/
|
||||
this.data_obj = await this.options.callbacks.initData();
|
||||
}
|
||||
|
||||
async fetchData(idx_start, idx_end) {
|
||||
if (!this.state.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await this.fetchDivIds(this.idxRangeToDivIds(idx_start, idx_end));
|
||||
const data_ids_sorted = Object.keys(data).sort((a, b) => {
|
||||
return this.data_obj_keys_sorted.indexOf(a) - this.data_obj_keys_sorted.indexOf(b);
|
||||
});
|
||||
|
||||
const res = [];
|
||||
for (const div_id of data_ids_sorted) {
|
||||
res.push(data[div_id]);
|
||||
this.lru.set(div_id, data[div_id]);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async sortData() {
|
||||
switch (this.sort_mode_str) {
|
||||
case "name":
|
||||
this.sort_fn = this.sortByName;
|
||||
break;
|
||||
case "path":
|
||||
this.sort_fn = this.sortByPath;
|
||||
break;
|
||||
case "date_created":
|
||||
this.sort_fn = this.sortByDateCreated;
|
||||
break;
|
||||
case "date_modified":
|
||||
this.sort_fn = this.sortByDateModified;
|
||||
break;
|
||||
default:
|
||||
this.sort_fn = this.default_sort_fn;
|
||||
break;
|
||||
}
|
||||
await super.sortData();
|
||||
}
|
||||
|
||||
async filterDataDefaultCallback() {
|
||||
/** Filters data by a string and returns number of items after filter. */
|
||||
let n_visible = 0;
|
||||
|
||||
for (const [div_id, v] of Object.entries(this.data_obj)) {
|
||||
let visible = true;
|
||||
|
||||
// Apply the directory filters.
|
||||
if (!Object.keys(this.directory_filters).length) {
|
||||
v.visible = true;
|
||||
} else {
|
||||
v.visible = Object.values(this.directory_filters).some((filter) => {
|
||||
if (filter.recurse) {
|
||||
return v.rel_parent_dir.startsWith(filter.filter_str);
|
||||
} else {
|
||||
return v.rel_parent_dir === filter.filter_str;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!v.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Narrow the filtered items based on the search string.
|
||||
// Custom filter for items marked search_only=true.
|
||||
if (v.search_only) {
|
||||
if (Object.keys(this.directory_filters).length || this.filter_str.length >= 4) {
|
||||
visible = v.search_terms.toLowerCase().indexOf(this.filter_str.toLowerCase()) !== -1;
|
||||
} else {
|
||||
visible = false;
|
||||
}
|
||||
} else {
|
||||
// All other filters treated case insensitive.
|
||||
visible = v.search_terms.toLowerCase().indexOf(this.filter_str.toLowerCase()) !== -1;
|
||||
}
|
||||
|
||||
this.data_obj[div_id].visible = visible;
|
||||
if (visible) {
|
||||
n_visible++;
|
||||
}
|
||||
}
|
||||
return n_visible;
|
||||
}
|
||||
}
|
||||
59
javascript/lru_cache.js
Normal file
59
javascript/lru_cache.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Prevent eslint errors on functions defined in other files.
|
||||
/*global
|
||||
isNumberThrowError,
|
||||
isNullOrUndefined,
|
||||
*/
|
||||
/*eslint no-undef: "error"*/
|
||||
|
||||
const LRU_CACHE_MAX_ITEMS_DEFAULT = 250;
|
||||
class LRUCache {
|
||||
/** Least Recently Used cache implementation.
|
||||
*
|
||||
* Source: https://stackoverflow.com/a/46432113
|
||||
*/
|
||||
constructor(max = LRU_CACHE_MAX_ITEMS_DEFAULT) {
|
||||
isNumberThrowError(max);
|
||||
|
||||
this.max = max;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clear();
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
let item = this.cache.get(key);
|
||||
if (!isNullOrUndefined(item)) {
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
set(key, val) {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size === this.max) {
|
||||
this.cache.delete(this.first());
|
||||
}
|
||||
this.cache.set(key, val);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
first() {
|
||||
return this.cache.keys().next().value;
|
||||
}
|
||||
}
|
||||
1300
javascript/resizeGrid.js
Normal file
1300
javascript/resizeGrid.js
Normal file
File diff suppressed because it is too large
Load diff
178
javascript/resizeGridExample.html
Normal file
178
javascript/resizeGridExample.html
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
background: #333333;
|
||||
margin: 50px;
|
||||
}
|
||||
|
||||
.my-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#cell_0_0 {
|
||||
background: #ffb3ba;
|
||||
}
|
||||
|
||||
#cell_0_1 {
|
||||
background: #ffdfba;
|
||||
}
|
||||
|
||||
#cell_1_0 {
|
||||
background: #baffc9;
|
||||
}
|
||||
|
||||
#cell_1_1 {
|
||||
background: #bae1ff;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.resizing {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
body.resizing * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.resizing .resize-grid--handle {
|
||||
pointer-events: initial !important;
|
||||
}
|
||||
|
||||
body.resizing.resize-grid-col {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
body.resizing.resize-grid-row {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 50vw;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid white;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.resize-grid,
|
||||
.resize-grid--row,
|
||||
.resize-grid--col,
|
||||
.resize-grid--cell {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
gap: 0 !important;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resize-grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resize-grid--row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.resize-grid--col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resize-grid--handle {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resize-grid--handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.resize-grid--row-handle {
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.resize-grid--row-handle::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-top: 1px dashed white;
|
||||
}
|
||||
|
||||
.resize-grid--col-handle {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.resize-grid--col-handle::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 1px dashed white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="resize-grid">
|
||||
<div class="resize-grid--row" style="flex: 1 0 50px;" data-min-size="25px">
|
||||
<div class="resize-grid--cell" style="flex: 0 0 50px;" data-min-size="25px">
|
||||
<div id="cell_0_0" class="my-content">0</div>
|
||||
</div>
|
||||
<div class="resize-grid--cell" style="flex: 1 0 50px;" data-min-size="25px">
|
||||
<div id="cell_0_1" class="my-content">1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-grid--row" style="flex: 0 0 50px;" data-min-size="25px">
|
||||
<div class="resize-grid--cell" style="flex: 1 0 50px;" data-min-size="25px">
|
||||
<div id="cell_1_0" class="my-content">2</div>
|
||||
</div>
|
||||
<div class="resize-grid--cell" style="flex: 0 0 50px;" data-min-size="25px">
|
||||
<div id="cell_1_1" class="my-content">3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="toggle-button" data-grid-elem-id="cell_0_0">HIDE cell_0_0</button>
|
||||
<button class="toggle-button" data-grid-elem-id="cell_0_1">HIDE cell_0_1</button>
|
||||
<button class="toggle-button" data-grid-elem-id="cell_1_0">HIDE cell_1_0</button>
|
||||
<button class="toggle-button" data-grid-elem-id="cell_1_1">HIDE cell_1_1</button>
|
||||
<script type="text/javascript" src="utils.js"></script>
|
||||
<script type="text/javascript" src="resizeGrid.js"></script>
|
||||
<script type="text/javascript">
|
||||
const grid_elem = document.querySelector(".resize-grid");
|
||||
const grid = resizeGridSetup(grid_elem);
|
||||
const btns = document.querySelectorAll(".toggle-button");
|
||||
btns.forEach(btn => {
|
||||
btn.addEventListener("click", onToggleButton);
|
||||
});
|
||||
function onToggleButton(event) {
|
||||
const btn = event.target.closest("button");
|
||||
const id = btn.dataset.gridElemId;
|
||||
const grid_elem = document.querySelector(`#${id}`);
|
||||
grid.toggle({ elem: grid_elem });
|
||||
btn.textContent = btn.textContent === `HIDE ${id}` ? `SHOW ${id}` : `HIDE ${id}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
(function() {
|
||||
const GRADIO_MIN_WIDTH = 320;
|
||||
const PAD = 16;
|
||||
const DEBOUNCE_TIME = 100;
|
||||
const DOUBLE_TAP_DELAY = 200; //ms
|
||||
const DEBOUNCE_TIME_MS = 250;
|
||||
const DOUBLE_TAP_DELAY_MS = 250;
|
||||
|
||||
const R = {
|
||||
tracking: false,
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
let parents = [];
|
||||
|
||||
function setLeftColGridTemplate(el, width) {
|
||||
el.style.gridTemplateColumns = `${width}px 16px 1fr`;
|
||||
el.style.gridTemplateColumns = `${width}px ${PAD}px 1fr`;
|
||||
}
|
||||
|
||||
function displayResizeHandle(parent) {
|
||||
|
|
@ -58,6 +58,9 @@
|
|||
evt.stopPropagation();
|
||||
|
||||
parent.style.gridTemplateColumns = parent.style.originalGridTemplateColumns;
|
||||
|
||||
// Fire a custom event so user can perform additional tasks on double click.
|
||||
parent.dispatchEvent(new CustomEvent("resizeHandleDblClick", {bubbles: true}));
|
||||
}
|
||||
|
||||
const leftCol = parent.firstElementChild;
|
||||
|
|
@ -101,7 +104,7 @@
|
|||
if (evt.changedTouches.length !== 1) return;
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
if (R.lastTapTime && currentTime - R.lastTapTime <= DOUBLE_TAP_DELAY) {
|
||||
if (R.lastTapTime && currentTime - R.lastTapTime <= DOUBLE_TAP_DELAY_MS) {
|
||||
onDoubleClick(evt);
|
||||
return;
|
||||
}
|
||||
|
|
@ -152,7 +155,13 @@
|
|||
} else {
|
||||
delta = R.screenX - evt.changedTouches[0].screenX;
|
||||
}
|
||||
const leftColWidth = Math.max(Math.min(R.leftColStartWidth - delta, R.parent.offsetWidth - R.parent.minRightColWidth - PAD), R.parent.minLeftColWidth);
|
||||
const leftColWidth = Math.max(
|
||||
Math.min(
|
||||
R.leftColStartWidth - delta,
|
||||
R.parent.offsetWidth - R.parent.minRightColWidth - PAD,
|
||||
),
|
||||
R.parent.minLeftColWidth,
|
||||
);
|
||||
setLeftColGridTemplate(R.parent, leftColWidth);
|
||||
}
|
||||
});
|
||||
|
|
@ -173,6 +182,13 @@
|
|||
R.tracking = false;
|
||||
|
||||
document.body.classList.remove('resizing');
|
||||
|
||||
// Fire a custom event at end of resizing.
|
||||
R.parent.dispatchEvent(
|
||||
new CustomEvent("resizeHandleResized", {
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -185,7 +201,7 @@
|
|||
for (const parent of parents) {
|
||||
afterResize(parent);
|
||||
}
|
||||
}, DEBOUNCE_TIME);
|
||||
}, DEBOUNCE_TIME_MS);
|
||||
});
|
||||
|
||||
setupResizeHandle = setup;
|
||||
|
|
|
|||
742
javascript/utils.js
Normal file
742
javascript/utils.js
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
/** Collators used for sorting. */
|
||||
const INT_COLLATOR = new Intl.Collator([], {numeric: true});
|
||||
const STR_COLLATOR = new Intl.Collator("en", {numeric: true, sensitivity: "base"});
|
||||
|
||||
/** Helper functions for checking types and simplifying logging/error handling. */
|
||||
function isNumber(x) {
|
||||
return typeof x === "number" && isFinite(x);
|
||||
}
|
||||
function isNumberLogError(x) {
|
||||
if (isNumber(x)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`expected number, got: ${typeof x}`);
|
||||
return false;
|
||||
}
|
||||
function isNumberThrowError(x) {
|
||||
if (isNumber(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected number, got: ${typeof x}`);
|
||||
}
|
||||
|
||||
function isString(x) {
|
||||
return typeof x === "string" || x instanceof String;
|
||||
}
|
||||
function isStringLogError(x) {
|
||||
if (isString(x)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`expected string, got: ${typeof x}`);
|
||||
return false;
|
||||
}
|
||||
function isStringThrowError(x) {
|
||||
if (isString(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected string, got: ${typeof x}`);
|
||||
}
|
||||
|
||||
function isNull(x) {
|
||||
return x === null;
|
||||
}
|
||||
function isUndefined(x) {
|
||||
return typeof x === "undefined" || x === undefined;
|
||||
}
|
||||
// checks both null and undefined for simplicity sake.
|
||||
function isNullOrUndefined(x) {
|
||||
return isNull(x) || isUndefined(x);
|
||||
}
|
||||
function isNullOrUndefinedLogError(x) {
|
||||
if (isNullOrUndefined(x)) {
|
||||
console.error("Variable is null/undefined.");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function isNullOrUndefinedThrowError(x) {
|
||||
if (!isNullOrUndefined(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Variable is null/undefined.");
|
||||
}
|
||||
|
||||
function isElement(x) {
|
||||
return x instanceof Element;
|
||||
}
|
||||
function isElementLogError(x) {
|
||||
if (isElement(x)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`expected element type, got: ${typeof x}`);
|
||||
return false;
|
||||
}
|
||||
function isElementThrowError(x) {
|
||||
if (isElement(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected element type, got: ${typeof x}`);
|
||||
}
|
||||
|
||||
function isFunction(x) {
|
||||
return typeof x === "function";
|
||||
}
|
||||
function isFunctionLogError(x) {
|
||||
if (isFunction(x)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`expected function type, got: ${typeof x}`);
|
||||
return false;
|
||||
}
|
||||
function isFunctionThrowError(x) {
|
||||
if (isFunction(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected function type, got: ${typeof x}`);
|
||||
}
|
||||
|
||||
function isObject(x) {
|
||||
return typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
function isObjectLogError(x) {
|
||||
if (isObject(x)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`expected object type, got: ${typeof x}`);
|
||||
return false;
|
||||
}
|
||||
function isObjectThrowError(x) {
|
||||
if (isObject(x)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected object type, got: ${typeof x}`);
|
||||
}
|
||||
|
||||
function keyExists(obj, k) {
|
||||
return isObject(obj) && isString(k) && k in obj;
|
||||
}
|
||||
function keyExistsLogError(obj, k) {
|
||||
if (keyExists(obj, k)) {
|
||||
return true;
|
||||
}
|
||||
console.error(`key does not exist in object: ${k}`);
|
||||
return false;
|
||||
}
|
||||
function keyExistsThrowError(obj, k) {
|
||||
if (keyExists(obj, k)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`key does not exist in object: ${k}`);
|
||||
}
|
||||
|
||||
function getValue(obj, k) {
|
||||
/** Returns value of object for given key if it exists, otherwise returns null. */
|
||||
if (keyExists(obj, k)) {
|
||||
return obj[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getValueLogError(obj, k) {
|
||||
if (keyExistsLogError(obj, k)) {
|
||||
return obj[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getValueThrowError(obj, k) {
|
||||
keyExistsThrowError(obj, k);
|
||||
return obj[k];
|
||||
}
|
||||
|
||||
function getElementByIdLogError(selector) {
|
||||
const elem = gradioApp().getElementById(selector);
|
||||
isElementLogError(elem);
|
||||
return elem;
|
||||
}
|
||||
function getElementByIdThrowError(selector) {
|
||||
const elem = gradioApp().getElementById(selector);
|
||||
isElementThrowError(elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
function querySelectorLogError(selector) {
|
||||
const elem = gradioApp().querySelector(selector);
|
||||
isElementLogError(elem);
|
||||
return elem;
|
||||
}
|
||||
function querySelectorThrowError(selector) {
|
||||
const elem = gradioApp().querySelector(selector);
|
||||
isElementThrowError(elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
const validateArrayType = (arr, type_check_fn) => {
|
||||
/** Validates that a variable is an array with members of a specified type.
|
||||
* `type_check_fn` must accept array elements as arguments and return whether
|
||||
* they match the expected type.
|
||||
* `arr` will be wrapped in an array if it is not already an array.
|
||||
*/
|
||||
isNullOrUndefinedThrowError(type_check_fn);
|
||||
if (isNullOrUndefined(arr)) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(arr) && type_check_fn(arr)) {
|
||||
return [arr];
|
||||
} else if (Array.isArray(arr) && arr.every((x) => type_check_fn(x))) {
|
||||
return arr;
|
||||
} else {
|
||||
throw new Error('Invalid array types:', arr);
|
||||
}
|
||||
};
|
||||
|
||||
/** Functions for getting dimensions of elements. */
|
||||
function getStyle(elem) {
|
||||
return window.getComputedStyle ? window.getComputedStyle(elem) : elem.currentStyle;
|
||||
}
|
||||
|
||||
function getComputedProperty(elem, prop) {
|
||||
return getStyle(elem)[prop];
|
||||
}
|
||||
|
||||
function getComputedPropertyDims(elem, prop) {
|
||||
/** Returns the top/left/bottom/right float dimensions of an element for the specified property. */
|
||||
const style = getStyle(elem);
|
||||
return {
|
||||
top: parseFloat(style.getPropertyValue(`${prop}-top`)),
|
||||
left: parseFloat(style.getPropertyValue(`${prop}-left`)),
|
||||
bottom: parseFloat(style.getPropertyValue(`${prop}-bottom`)),
|
||||
right: parseFloat(style.getPropertyValue(`${prop}-right`)),
|
||||
};
|
||||
}
|
||||
|
||||
function getComputedMarginDims(elem) {
|
||||
/** Returns the width/height of the computed margin of an element. */
|
||||
const dims = getComputedPropertyDims(elem, "margin");
|
||||
return {
|
||||
width: dims.left + dims.right,
|
||||
height: dims.top + dims.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getComputedPaddingDims(elem) {
|
||||
/** Returns the width/height of the computed padding of an element. */
|
||||
const dims = getComputedPropertyDims(elem, "padding");
|
||||
return {
|
||||
width: dims.left + dims.right,
|
||||
height: dims.top + dims.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getComputedBorderDims(elem) {
|
||||
/** Returns the width/height of the computed border of an element. */
|
||||
// computed border will always start with the pixel width so thankfully
|
||||
// the parseFloat() conversion will just give us the width and ignore the rest.
|
||||
// Otherwise we'd have to use border-<pos>-width instead.
|
||||
const dims = getComputedPropertyDims(elem, "border");
|
||||
return {
|
||||
width: dims.left + dims.right,
|
||||
height: dims.top + dims.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getComputedDims(elem) {
|
||||
/** Returns the full width and height of an element including its margin, padding, and border. */
|
||||
const width = elem.scrollWidth;
|
||||
const height = elem.scrollHeight;
|
||||
const margin = getComputedMarginDims(elem);
|
||||
const padding = getComputedPaddingDims(elem);
|
||||
const border = getComputedBorderDims(elem);
|
||||
return {
|
||||
width: width + margin.width + padding.width + border.width,
|
||||
height: height + margin.height + padding.height + border.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Functions for asynchronous operations. */
|
||||
|
||||
function waitForElement(selector, timeout_ms) {
|
||||
/** Promise that waits for an element to exist in DOM. */
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(selector)) {
|
||||
return resolve(document.querySelector(selector));
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(mutations => {
|
||||
if (document.querySelector(selector)) {
|
||||
observer.disconnect();
|
||||
return resolve(document.querySelector(selector));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
if (isNumber(timeout_ms) && timeout_ms !== 0) {
|
||||
setTimeout(() => {
|
||||
observer.takeRecords();
|
||||
observer.disconnect();
|
||||
return reject(`timed out waiting for element: "${selector}"`);
|
||||
}, timeout_ms);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function waitForBool(o, timeout_ms) {
|
||||
/** Promise that waits for a boolean to be true.
|
||||
*
|
||||
* `o` must be an Object of the form:
|
||||
* { state: <bool value> }
|
||||
*
|
||||
* If timeout_ms is null/undefined or 0, waits forever.
|
||||
*
|
||||
* Resolves when (state === true)
|
||||
* Rejects when state is not True before timeout_ms.
|
||||
*/
|
||||
let wait_timer;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isNumber(timeout_ms) && timeout_ms !== 0) {
|
||||
setTimeout(() => {
|
||||
clearTimeout(wait_timer);
|
||||
return reject("timed out waiting for bool");
|
||||
}, timeout_ms);
|
||||
}
|
||||
(function _waitForBool() {
|
||||
if (o.state) {
|
||||
return resolve();
|
||||
}
|
||||
wait_timer = setTimeout(_waitForBool, 100);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForKeyInObject(o, timeout_ms) {
|
||||
/** Promise that waits for a key to exist in an object.
|
||||
*
|
||||
* `o` must be an Object of the form:
|
||||
* {
|
||||
* obj: <object to watch for key>,
|
||||
* k: <key to watch for>,
|
||||
* }
|
||||
*
|
||||
* If timeout_ms is null/undefined or 0, waits forever.
|
||||
*
|
||||
* Resolves when (k in obj).
|
||||
* Rejects when k is not found in obj before timeout_ms.
|
||||
*/
|
||||
let wait_timer;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isNumber(timeout_ms) && timeout_ms !== 0) {
|
||||
setTimeout(() => {
|
||||
clearTimeout(wait_timer);
|
||||
return reject(`timed out waiting for key: ${o.k}`);
|
||||
}, timeout_ms);
|
||||
}
|
||||
(function _waitForKeyInObject() {
|
||||
if (o.k in o.obj) {
|
||||
return resolve();
|
||||
}
|
||||
wait_timer = setTimeout(_waitForKeyInObject, 100);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForValueInObject(o, timeout_ms) {
|
||||
/** Promise that waits for a key value pair in an Object.
|
||||
*
|
||||
* `o` must be an Object of the form:
|
||||
* {
|
||||
* obj: <object containing value>,
|
||||
* k: <key in object>,
|
||||
* v: <value at key for comparison>
|
||||
* }
|
||||
*
|
||||
* If timeout_ms is null/undefined or 0, waits forever.
|
||||
*
|
||||
* Resolves when obj[k] == v
|
||||
*/
|
||||
let wait_timer;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isNumber(timeout_ms) && timeout_ms !== 0) {
|
||||
setTimeout(() => {
|
||||
clearTimeout(wait_timer);
|
||||
return reject(`timed out waiting for value: ${o.k}: ${o.v}`);
|
||||
}, timeout_ms);
|
||||
}
|
||||
waitForKeyInObject({k: o.k, obj: o.obj}, timeout_ms).then(() => {
|
||||
(function _waitForValueInObject() {
|
||||
|
||||
if (o.k in o.obj && o.obj[o.k] == o.v) {
|
||||
return resolve();
|
||||
}
|
||||
setTimeout(_waitForValueInObject, 100);
|
||||
})();
|
||||
}).catch((error) => {
|
||||
return reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests */
|
||||
|
||||
class FetchError extends Error {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
class Fetch4xxError extends FetchError {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
class Fetch5xxError extends FetchError {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
class FetchRetryLimitError extends FetchError {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
class FetchTimeoutError extends FetchError {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
class FetchWithRetryAndBackoffTimeoutError extends FetchError {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetryAndBackoff(url, data, args = {}) {
|
||||
/** Wrapper around `fetch` with retries, backoff, and timeout.
|
||||
*
|
||||
* Uses a Decorrelated jitter backoff strategy.
|
||||
* https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
||||
*
|
||||
* Args:
|
||||
* url: Primary URL to fetch.
|
||||
* data: Data to append to the URL when making the request.
|
||||
* args:
|
||||
* method: The HTTP request method to use.
|
||||
* timeout_ms: Max allowed time before this function fails.
|
||||
* fetch_timeout_ms: Max allowed time for individual `fetch` calls.
|
||||
* min_delay_ms: Min time that a delay between requests can be.
|
||||
* max_delay_ms: Max time that a delay between reqeusts can be.
|
||||
* response_handler: A callback function that returns a promise.
|
||||
* This function is sent the response from the `fetch` call.
|
||||
* If not specified, all status codes >= 400 are handled as errors.
|
||||
* This is useful for handling requests whose responses from the server
|
||||
* are erronious but the HTTP status is 200.
|
||||
*/
|
||||
args.method = args.method || "GET";
|
||||
args.timeout_ms = args.timeout_ms || 30000;
|
||||
args.min_delay_ms = args.min_delay_ms || 100;
|
||||
args.max_delay_ms = args.max_delay_ms || 3000;
|
||||
args.fetch_timeout_ms = args.fetch_timeout_ms || 10000;
|
||||
// The default response handler function for `fetch` call responses.
|
||||
const response_handler = (response) => new Promise((resolve, reject) => {
|
||||
if (response.ok) {
|
||||
return response.json().then(json => {
|
||||
return resolve(json);
|
||||
});
|
||||
} else {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new Fetch4xxError("client error:", response);
|
||||
}
|
||||
if (response.status >= 500 && response.status < 600) {
|
||||
throw new Fetch5xxError("server error:", response);
|
||||
}
|
||||
return reject(response);
|
||||
}
|
||||
});
|
||||
args.response_handler = args.response_handler || response_handler;
|
||||
|
||||
const url_args = Object.entries(data).map(([k, v]) => {
|
||||
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
|
||||
}).join("&");
|
||||
url = `${url}?${url_args}`;
|
||||
|
||||
let controller;
|
||||
let retry = true;
|
||||
|
||||
const randrange = (min, max) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
};
|
||||
const get_jitter = (base, max, prev) => {
|
||||
return Math.min(max, randrange(base, prev * 3));
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Timeout for individual `fetch` calls.
|
||||
const fetch_timeout = (ms, promise) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
return reject(new FetchTimeoutError("Fetch timed out."));
|
||||
}, ms);
|
||||
return promise.then(resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
// Timeout for all retries.
|
||||
const run_timeout = (ms, promise) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
retry = false;
|
||||
controller.abort();
|
||||
return reject(new FetchWithRetryAndBackoffTimeoutError("Request timed out."));
|
||||
}, ms);
|
||||
return promise.then(resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
const run = async(delay_ms) => {
|
||||
if (!retry) {
|
||||
// Retry is controlled externally via `run_timeout`. This function's promise
|
||||
// is also handled via that timeout so we can just return here.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
controller = new AbortController();
|
||||
const fetch_opts = {method: args.method, signal: controller.signal};
|
||||
const response = await fetch_timeout(args.fetch_timeout_ms, fetch(url, fetch_opts));
|
||||
return await args.response_handler(response);
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
// dont bother with anything else if told to not retry.
|
||||
if (!retry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Fetch4xxError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Fetch5xxError) {
|
||||
throw error;
|
||||
}
|
||||
// Any other errors mean we need to retry the request.
|
||||
delay_ms = get_jitter(args.min_delay_ms, args.max_delay_ms, delay_ms);
|
||||
await delay(delay_ms);
|
||||
return await run(delay_ms);
|
||||
}
|
||||
};
|
||||
return await run_timeout(args.timeout_ms, run(args.min_delay_ms));
|
||||
}
|
||||
|
||||
function requestGet(url, data, handler, errorHandler) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var args = Object.keys(data).map(function(k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]);
|
||||
}).join('&');
|
||||
xhr.open("GET", url + "?" + args, true);
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var js = JSON.parse(xhr.responseText);
|
||||
handler(js);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
errorHandler();
|
||||
}
|
||||
} else {
|
||||
errorHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
var js = JSON.stringify(data);
|
||||
xhr.send(js);
|
||||
}
|
||||
|
||||
function requestGetPromise(url, data, timeout_ms) {
|
||||
/**Asynchronous `GET` request that returns a promise.
|
||||
*
|
||||
* The result will be of the format {status: int, response: JSON object}.
|
||||
* Thus, the xhr.responseText that we receive is expected to be a JSON string.
|
||||
* Acceptable status codes for successful requests are 200 <= status < 300.
|
||||
*/
|
||||
if (!isNumber(timeout_ms)) {
|
||||
timeout_ms = 1000;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const args = Object.entries(data).map(([k, v]) => {
|
||||
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
|
||||
}).join("&");
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
return resolve({status: xhr.status, response: JSON.parse(xhr.responseText)});
|
||||
} else {
|
||||
return reject({status: xhr.status, response: JSON.parse(xhr.responseText)});
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
return reject({status: xhr.status, response: JSON.parse(xhr.responseText)});
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
return reject({status: 408, response: {detail: `Request timeout: ${url}`}});
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(data);
|
||||
xhr.open("GET", `${url}?${args}`, true);
|
||||
xhr.timeout = timeout_ms;
|
||||
xhr.send(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/** Misc helper functions. */
|
||||
|
||||
function clamp(x, min, max) {
|
||||
return Math.max(min, Math.min(x, max));
|
||||
}
|
||||
|
||||
function htmlStringToElement(s) {
|
||||
/** Converts an HTML string into an Element type. */
|
||||
let parser = new DOMParser();
|
||||
let tmp = parser.parseFromString(s, "text/html");
|
||||
return tmp.body.firstElementChild;
|
||||
}
|
||||
|
||||
function htmlStringToFragment(s) {
|
||||
/** Converts an HTML string into a DocumentFragment. */
|
||||
return document.createRange().createContextualFragment(s);
|
||||
}
|
||||
|
||||
function convertInnerHtmlToShadowDOM(elem) {
|
||||
/** Inplace conversion of innerHTML of an element into a Shadow DOM.
|
||||
*
|
||||
* If the innerHTML is not valid HTML then the innerHTML is left unchanged.
|
||||
*/
|
||||
const parsed_str = new DOMParser().parseFromString(elem.innerHTML, "text/html").documentElement.textContent;
|
||||
const parsed_elem = htmlStringToElement(parsed_str);
|
||||
if (!isNullOrUndefined(parsed_elem)) {
|
||||
elem.innerHTML = "";
|
||||
const shadow = elem.attachShadow({mode: "open"});
|
||||
shadow.appendChild(parsed_elem);
|
||||
}
|
||||
}
|
||||
|
||||
function convertElementShadowDOM(elem, selector) {
|
||||
/** Inplace conversion of Shadow DOM of all children matching the passed selector.
|
||||
*
|
||||
* `selector` defaults to [data-parse-as-shadow-dom] if not a valid string.
|
||||
*
|
||||
* NOTE: Nested Shadow DOMs are untested but will likely not work.
|
||||
*/
|
||||
if (!isString(selector)) {
|
||||
selector = "[data-parse-as-shadow-dom]";
|
||||
}
|
||||
|
||||
let children = Array.from(elem.querySelectorAll(selector));
|
||||
children = children.filter(x => x.innerHTML !== "");
|
||||
for (const child of children) {
|
||||
convertInnerHtmlToShadowDOM(child);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCss(key, css, enable) {
|
||||
var style = document.getElementById(key);
|
||||
if (enable && !style) {
|
||||
style = document.createElement('style');
|
||||
style.id = key;
|
||||
style.type = 'text/css';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
if (style && !enable) {
|
||||
document.head.removeChild(style);
|
||||
}
|
||||
if (style) {
|
||||
style.innerHTML == '';
|
||||
style.appendChild(document.createTextNode(css));
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(s) {
|
||||
/** Copies the passed string to the clipboard. */
|
||||
isStringThrowError(s);
|
||||
navigator.clipboard.writeText(s);
|
||||
}
|
||||
|
||||
function attrPromise({elem, attr, timeout_ms} = {}) {
|
||||
timeout_ms = timeout_ms || 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
let res = false;
|
||||
const observer_config = {attributes: true, attributeOldValue: true};
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (isString(attr) && mutation.attributeName === attr) {
|
||||
res = true;
|
||||
observer.disconnect();
|
||||
resolve(elem, elem.getAttribute(attr));
|
||||
}
|
||||
if (!isString(attr)) {
|
||||
res = true;
|
||||
observer.disconnect();
|
||||
resolve(elem);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (timeout_ms > 0) {
|
||||
setTimeout(() => {
|
||||
if (!res) {
|
||||
reject(elem);
|
||||
}
|
||||
}, timeout_ms);
|
||||
}
|
||||
|
||||
if (isString(attr)) {
|
||||
observer_config.attributeFilter = [attr];
|
||||
}
|
||||
observer.observe(elem, observer_config);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForVisible(elem, callback) {
|
||||
new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.intersectionRatio > 0) {
|
||||
callback(elem);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}).observe(elem);
|
||||
if (!callback) return new Promise(resolve => callback = resolve);
|
||||
}
|
||||
|
||||
function cssRelativeUnitToPx(css_value, target) {
|
||||
// https://stackoverflow.com/a/66569574
|
||||
// doesnt work on `%` unit.
|
||||
target = target || document.body;
|
||||
const units = {
|
||||
px: x => x, // no conversion needed here
|
||||
rem: x => x * parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
em: x => x * parseFloat(getComputedStyle(target).fontSize),
|
||||
vw: x => x / 100 * window.innerWidth,
|
||||
vh: x => x / 100 * window.innerHeight,
|
||||
};
|
||||
|
||||
const re = new RegExp(`^([-+]?(?:\\d+(?:\\.\\d+)?))(${Object.keys(units).join('|')})$`, 'i');
|
||||
const matches = css_value.toString().trim().match(re);
|
||||
if (matches) {
|
||||
const value = Number(matches[1]);
|
||||
const unit = matches[2].toLocaleLowerCase();
|
||||
if (unit in units) {
|
||||
return units[unit](value);
|
||||
}
|
||||
}
|
||||
return css_value;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue