This commit is contained in:
Sj-Si 2025-12-21 19:59:37 -05:00 committed by GitHub
commit 68579b9f78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 8027 additions and 1629 deletions

622
javascript/clusterize.js Normal file
View 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

View 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
View 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

File diff suppressed because it is too large Load diff

View 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>

View file

@ -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
View 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;
}