update to use new clusterize class

This commit is contained in:
Sj-Si 2024-04-09 07:19:07 -04:00
parent 806bbff5a5
commit ec80d57e8b
6 changed files with 1636 additions and 2037 deletions

View file

@ -17,10 +17,32 @@ class Clusterize {
content_elem = null;
scroll_id = null;
content_id = null;
#options = {};
default_sort_mode_str = "";
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;
options = {
rows_in_block: 50,
cols_in_block: 1,
blocks_in_cluster: 5,
tag: null,
show_no_data_row: true,
no_data_class: "clusterize-no-data",
no_data_text: "No data",
keep_parity: true,
callbacks: {
initData: this.initDataDefault,
fetchData: this.fetchDataDefault,
sortData: this.sortDataDefault,
filterData: this.filterDataDefault,
},
};
#is_mac = null;
#ie = null;
#n_rows = null;
#max_items = null;
#max_rows = null;
#cache = {};
#scroll_top = 0;
#last_cluster = false;
@ -30,59 +52,46 @@ class Clusterize {
#element_observer = null;
#element_observer_timer = null;
#pointer_events_set = false;
#sort_mode = "";
#sort_dir = "";
constructor(args) {
const defaults = {
rows_in_block: 50,
blocks_in_cluster: 4,
tag: null,
show_no_data_row: true,
no_data_class: 'clusterize-no-data',
no_data_text: 'No data',
keep_parity: true,
callbacks: {}
};
for (const option of Object.keys(this.options)) {
if (keyExists(args, option)) {
this.options[option] = args[option];
}
}
const options = [
'rows_in_block',
'blocks_in_cluster',
'show_no_data_row',
'no_data_class',
'no_data_text',
'keep_parity',
'tag',
'callbacks',
];
if (!isNullOrUndefined(this.options.callbacks.initData)) {
this.options.callbacks.initData = this.initDataDefault;
}
if (!isNullOrUndefined(this.options.callbacks.fetchData)) {
this.options.callbacks.fetchData = this.fetchDataDefault;
}
if (!isNullOrUndefined(this.options.callbacks.sortData)) {
this.options.callbacks.sortData = this.sortDataDefault;
}
if (!isNullOrUndefined(this.options.callbacks.filterData)) {
this.options.callbacks.filterData = this.filterDataDefault;
}
// detect ie9 and lower
// https://gist.github.com/padolsey/527683#comment-786682
this.#ie = (function () {
for (var v = 3,
el = document.createElement('b'),
el = document.createElement("b"),
all = el.all || [];
el.innerHTML = '<!--[if gt IE ' + (++v) + ']><i><![endif]-->',
el.innerHTML = `<!--[if gt IE ${++v}]><i><![endif]-->`,
all[0];
) { }
return v > 4 ? v : document.documentMode;
}())
this.#is_mac = navigator.platform.toLowerCase().indexOf('mac') + 1;
for (let i = 0, option; option = options[i]; i++) {
this.#options[option] = !isNullOrUndefined(args[option]) ? args[option] : defaults[option];
}
this.#is_mac = navigator.platform.toLowerCase().indexOf("mac") + 1;
this.scroll_elem = args["scrollId"] ? document.getElementById(args["scrollId"]) : args["scrollElem"];
if (!isElement(this.scroll_elem)) {
throw new Error("Error! Could not find scroll element");
}
isElementThrowError(this.scroll_elem);
this.scroll_id = this.scroll_elem.id;
this.content_elem = args["contentId"] ? document.getElementById(args["contentId"]) : args["contentElem"];
if (!isElement(this.content_elem)) {
throw new Error("Error! Could not find content element");
}
isElementThrowError(this.content_elem);
this.content_id = this.content_elem.id;
if (!this.content_elem.hasAttribute("tabindex")) {
@ -91,20 +100,7 @@ class Clusterize {
this.#scroll_top = this.scroll_elem.scrollTop;
if (!isNumber(args.n_rows)) {
throw new Error("Invalid argument. n_rows expected number, got:", typeof args.n_rows);
}
this.#n_rows = args.n_rows;
if (!this.#options.callbacks.fetchData) {
this.#options.callbacks.fetchData = this.#fetchDataDefault;
}
if (!this.#options.callbacks.sortData) {
this.#options.callbacks.sortData = this.#sortDataDefault;
}
if (!this.#options.callbacks.filterData) {
this.#options.callbacks.filterData = this.#filterDataDefault;
}
this.#max_items = args.max_items;
}
// ==== PUBLIC FUNCTIONS ====
@ -117,23 +113,27 @@ class Clusterize {
this.#setupResizeObservers();
}
clear() {
this.#html(this.#generateEmptyRow().join(""));
}
destroy() {
this.#teardownEvent("scroll", this.scroll_elem, this.#onScroll);
this.#teardownElementObservers();
this.#teardownResizeObservers();
this.#html(this.#generateEmptyRow().join(""));
this.clear();
}
refresh(force) {
async refresh(force) {
if (this.#getRowsHeight() || force) {
this.update()
await this.update()
}
}
async update() {
this.#scroll_top = this.scroll_elem.scrollTop;
// fixes #39
if (this.#n_rows * this.#options.item_height < this.#scroll_top) {
if (this.#max_rows * this.options.item_height < this.#scroll_top) {
this.scroll_elem.scrollTop = 0;
this.#last_cluster = 0;
}
@ -143,119 +143,166 @@ class Clusterize {
}
getRowsAmount() {
return this.#n_rows;
return this.#max_rows;
}
getScrollProgress() {
return this.#options.scroll_top / (this.#n_rows * this.#options.item_height) * 100 || 0;
return this.options.scroll_top / (this.#max_rows * this.options.item_height) * 100 || 0;
}
async filterData(filter) {
async setMaxItems(max_items) {
if (max_items === this.#max_items) {
// No change. do nothing.
return;
}
// If the number of items changed, we need to update the cluster.
this.#max_items = max_items;
await this.refresh(true);
// Apply sort to the updated data.
await this.sortData(this.sort_mode_str, this.sort_dir_str);
}
async filterData(filter_str) {
if (this.filter_str === filter_str) {
return;
}
this.filter_str = isNullOrUndefined(filter_str) ? this.default_filter_str : filter_str;
// Filter is applied to entire dataset.
const n_rows = await this.#options.callbacks.filterData(filter);
// If the number of rows changed after filter, we need to update the cluster.
if (n_rows !== this.#n_rows) {
this.#n_rows = n_rows;
const max_items = await this.options.callbacks.filterData(this.filter_str);
// If the number of items changed after filter, we need to update the cluster.
if (max_items !== this.#max_items) {
this.#max_items = max_items;
this.refresh(true);
}
// Apply sort to the new filtered data.
await this.sortData(this.#sort_mode, this.#sort_dir);
await this.sortData(this.sort_mode_str, this.sort_dir_str);
}
async sortData(mode, dir) {
async sortData(sort_mode_str, sort_dir_str) {
if (this.sort_mode_str === sort_mode_str && this.sort_dir_str === sort_dir_str) {
return;
}
this.sort_mode_str = isNullOrUndefined(sort_mode_str) ? this.default_sort_mode_str : sort_mode_str;
this.sort_dir_str = isNullOrUndefined(sort_dir_str) ? this.default_sort_dir_str : sort_dir_str;
// Sort is applied to the filtered data.
// update instance sort settings to the passed values.
this.#sort_mode = mode;
this.#sort_dir = dir;
await this.#options.callbacks.sortData(this.#sort_mode, this.#sort_dir === "descending");
await this.options.callbacks.sortData(this.sort_mode_str, this.sort_dir_str === "descending");
await this.#insertToDOM();
}
// ==== PRIVATE FUNCTIONS ====
initDataDefault() {
return Promise.resolve({});
}
#fetchDataDefault() {
fetchDataDefault() {
return Promise.resolve([]);
}
#sortDataDefault() {
return Promise.resolve([]);
sortDataDefault() {
return Promise.resolve();
}
#filterDataDefault() {
return Promise.resolve([]);
filterDataDefault() {
return Promise.resolve(0);
}
#exploreEnvironment(rows, cache) {
this.#options.content_tag = this.content_elem.tagName.toLowerCase();
this.options.content_tag = this.content_elem.tagName.toLowerCase();
if (!rows.length) {
return;
}
if (this.#ie && this.#ie <= 9 && !this.#options.tag) {
this.#options.tag = rows[0].match(/<([^>\s/]*)/)[1].toLowerCase();
if (this.#ie && this.#ie <= 9 && !this.options.tag) {
this.options.tag = rows[0].match(/<([^>\s/]*)/)[1].toLowerCase();
}
if (this.content_elem.children.length <= 1) {
cache.data = this.#html(rows[0] + rows[0] + rows[0]);
}
if (!this.#options.tag) {
this.#options.tag = this.content_elem.children[0].tagName.toLowerCase();
if (!this.options.tag) {
this.options.tag = this.content_elem.children[0].tagName.toLowerCase();
}
this.#getRowsHeight();
}
#getRowsHeight() {
const prev_item_height = this.#options.item_height;
const prev_rows_in_block = this.#options.rows_in_block;
const prev_item_height = this.options.item_height;
const prev_item_width = this.options.item_width;
const prev_rows_in_block = this.options.rows_in_block;
const prev_cols_in_block = this.options.cols_in_block;
this.#options.cluster_height = 0;
if (!this.#n_rows) {
this.options.cluster_height = 0;
this.options.cluster_width = 0;
if (!this.#max_items) {
return;
}
const nodes = this.content_elem.children;
const nodes = this.content_elem.querySelectorAll(":not(.clusterize-row)");
if (!nodes.length) {
return;
}
const node = nodes[Math.floor(nodes.length / 2)];
this.#options.item_height = node.offsetHeight;
const node_dims = getComputedDims(node);
this.options.item_height = node_dims.height;
this.options.item_width = node_dims.width;
// consider table's browser spacing
if (this.#options.tag === "tr" && getStyle("borderCollapse", this.content_elem) !== "collapse") {
this.#options.item_height += parseInt(getStyle("borderSpacing", this.content_elem), 10) || 0;
if (this.options.tag === "tr" && getStyle("borderCollapse", this.content_elem) !== "collapse") {
const spacing = parseInt(getStyle("borderSpacing", this.content_elem), 10) || 0;
this.options.item_height += spacing;
this.options.item_width += spacing;
}
// consider margins and margins collapsing
if (this.#options.tag !== "tr") {
if (this.options.tag !== "tr") {
const margin_top = parseInt(getStyle("marginTop", node), 10) || 0;
const margin_right = parseInt(getStyle("marginRight", node), 10) || 0;
const margin_bottom = parseInt(getStyle("marginBottom", node), 10) || 0;
this.#options.item_height += Math.max(margin_top, margin_bottom);
const margin_left = parseInt(getStyle("marginLeft", node), 10) || 0;
this.options.item_height += Math.max(margin_top, margin_bottom);
this.options.item_width += Math.max(margin_left, margin_right);
}
// Update rows in block to match the number of elements that can fit in the scroll element view.
this.#options.rows_in_block = parseInt(this.scroll_elem.clientHeight / this.#options.item_height);
this.options.rows_in_block = calcRowsPerCol(this.scroll_elem, node);
this.options.cols_in_block = calcColsPerRow(this.content_elem, node);
this.#options.block_height = this.#options.item_height * this.#options.rows_in_block;
this.#options.rows_in_cluster = this.#options.blocks_in_cluster * this.#options.rows_in_block;
this.#options.cluster_height = this.#options.blocks_in_cluster * this.#options.block_height;
return prev_item_height !== this.#options.item_height || prev_rows_in_block !== this.#options.rows_in_block;
this.options.block_height = this.options.item_height * this.options.rows_in_block;
this.options.block_width = this.options.item_width * this.options.cols_in_block;
this.options.rows_in_cluster = this.options.blocks_in_cluster * this.options.rows_in_block;
this.options.cluster_height = this.options.blocks_in_cluster * this.options.block_height;
this.options.cluster_width = this.options.block_width;
this.#max_rows = parseInt(this.#max_items / this.options.cols_in_block, 10);
return (
prev_item_height !== this.options.item_height ||
prev_item_width !== this.options.item_width ||
prev_rows_in_block !== this.options.rows_in_block ||
prev_cols_in_block !== this.options.cols_in_block
);
}
#getClusterNum() {
this.#options.scroll_top = this.scroll_elem.scrollTop;
const cluster_divider = this.#options.cluster_height - this.#options.block_height;
const current_cluster = Math.floor(this.#options.scroll_top / cluster_divider);
const max_cluster = Math.floor((this.#n_rows * this.#options.item_height) / cluster_divider);
this.options.scroll_top = this.scroll_elem.scrollTop;
const cluster_divider = this.options.cluster_height - this.options.block_height;
const current_cluster = Math.floor(this.options.scroll_top / cluster_divider);
const max_cluster = Math.floor((this.#max_rows * this.options.item_height) / cluster_divider);
return Math.min(current_cluster, max_cluster);
}
#generateEmptyRow() {
if (!this.#options.tag || !this.#options.show_no_data_row) {
if (!this.options.tag || !this.options.show_no_data_row) {
return [];
}
const empty_row = document.createElement(this.#options.tag);
const no_data_content = document.createTextNode(this.#options.no_data_text);
empty_row.className = this.#options.no_data_class;
if (this.#options.tag === "tr") {
const empty_row = document.createElement(this.options.tag);
const no_data_content = document.createTextNode(this.options.no_data_text);
empty_row.className = this.options.no_data_class;
if (this.options.tag === "tr") {
const td = document.createElement("td");
// fixes #53
td.colSpan = 100;
@ -268,13 +315,15 @@ class Clusterize {
}
async #generate() {
const items_start = Math.max((this.#options.rows_in_cluster - this.#options.rows_in_block) * this.#getClusterNum(), 0);
const items_end = items_start + this.#options.rows_in_cluster;
const top_offset = Math.max(items_start * this.#options.item_height, 0);
const bottom_offset = Math.max((this.#n_rows - items_end) * this.#options.item_height, 0);
const rows_above = top_offset < 1 ? items_start + 1 : items_start;
const rows_start = Math.max(0, (this.options.rows_in_cluster - this.options.rows_in_block) * this.#getClusterNum());
const rows_end = rows_start + this.options.rows_in_cluster;
const top_offset = Math.max(0, rows_start * this.options.item_height);
const bottom_offset = Math.max(0, (this.#max_rows - rows_end) * this.options.item_height);
const rows_above = top_offset < 1 ? rows_start + 1 : rows_start;
const this_cluster_rows = await this.#options.callbacks.fetchData(items_start, items_end);
const idx_start = Math.max(0, rows_start * this.options.cols_in_block);
const idx_end = rows_end * this.options.cols_in_block;
const this_cluster_rows = await this.options.callbacks.fetchData(idx_start, idx_end);
return {
top_offset: top_offset,
bottom_offset: bottom_offset,
@ -284,13 +333,18 @@ class Clusterize {
}
async #insertToDOM() {
if (!this.#options.cluster_height) {
const rows = await this.#options.callbacks.fetchData(0, 1);
if (!this.options.cluster_height || !this.options.cluster_width) {
const rows = await this.options.callbacks.fetchData(0, 1);
this.#exploreEnvironment(rows, this.#cache);
}
const data = await this.#generate();
const this_cluster_rows = data.rows.join("");
let this_cluster_rows = [];
for (let i = 0; i < data.rows.length; i += this.options.cols_in_block) {
const new_row = data.rows.slice(i, i + this.options.cols_in_block).join("");
this_cluster_rows.push(`<div class="clusterize-row">${new_row}</div>`);
}
this_cluster_rows = this_cluster_rows.join("");
const this_cluster_content_changed = this.#checkChanges("data", this_cluster_rows, this.#cache);
const top_offset_changed = this.#checkChanges("top", data.top_offset, this.#cache);
const only_bottom_offset_changed = this.#checkChanges("bottom", data.bottom_offset, this.#cache);
@ -298,16 +352,16 @@ class Clusterize {
if (this_cluster_content_changed || top_offset_changed) {
if (data.top_offset) {
this.#options.keep_parity && layout.push(this.#renderExtraTag("keep-parity"));
this.options.keep_parity && layout.push(this.#renderExtraTag("keep-parity"));
layout.push(this.#renderExtraTag("top-space", data.top_offset));
}
layout.push(this_cluster_rows);
data.bottom_offset && layout.push(this.#renderExtraTag("bottom-space", data.bottom_offset));
this.#options.callbacks.clusterWillChange && this.#options.callbacks.clusterWillChange();
this.options.callbacks.clusterWillChange && this.options.callbacks.clusterWillChange();
this.#html(layout.join(""));
this.#options.content_tag === "ol" && this.content_elem.setAttribute("start", data.rows_above);
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}`;
this.#options.callbacks.clusterChanged && this.#options.callbacks.clusterChanged();
this.options.callbacks.clusterChanged && this.options.callbacks.clusterChanged();
} else if (only_bottom_offset_changed) {
this.content_elem.lastChild.style.height = `${data.bottom_offset}px`;
}
@ -315,7 +369,7 @@ class Clusterize {
#html(data) {
const content_elem = this.content_elem;
if (this.#ie && this.#ie <= 9 && this.#options.tag === "tr") {
if (this.#ie && this.#ie <= 9 && this.options.tag === "tr") {
const div = document.createElement("div");
let last;
div.innerHTML = `<table><tbody>${data}</tbody></table>`;
@ -332,7 +386,7 @@ class Clusterize {
}
#renderExtraTag(class_name, height) {
const tag = document.createElement(this.#options.tag);
const tag = document.createElement(this.options.tag);
const clusterize_prefix = "clusterize-";
tag.className = [
`${clusterize_prefix}extra-row`,
@ -374,8 +428,8 @@ class Clusterize {
if (this.#last_cluster !== (this.#last_cluster = this.#getClusterNum())) {
await this.#insertToDOM();
}
if (this.#options.callbacks.scrollingProgress) {
this.#options.callbacks.scrollingProgress(this.getScrollingProgress());
if (this.options.callbacks.scrollingProgress) {
this.options.callbacks.scrollingProgress(this.getScrollProgress());
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,337 @@
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 = [];
sort_reverse = false;
default_sort_fn = this.sortByDivId;
sort_fn = this.default_sort_fn;
tabname = "";
extra_networks_tabname = "";
// 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;
constructor(...args) {
super(...args);
// finish initialization
this.tabname = getValueThrowError(...args, "tabname");
this.extra_networks_tabname = getValueThrowError(...args, "extra_networks_tabname");
}
sortByDivId() {
/** Sort data_obj keys (div_id) as numbers. */
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort(INT_COLLATOR.compare);
}
clear() {
this.data_obj = {};
this.data_obj_keys_sorted = [];
super.clear();
}
async initDataDefault() {
/**Fetches the initial data.
*
* This data should be minimal and only contain div IDs and other necessary
* information such as sort keys and terms for filtering.
*/
throw new NotImplementedError();
}
async fetchDataDefault(idx_start, idx_end) {
throw new NotImplementedError();
}
async sortDataDefault(sort_mode_str, sort_dir_str) {
this.sort_mode_str = sort_mode_str;
this.sort_dir_str = sort_dir_str;
this.sort_reverse = sort_dir_str === "descending";
this.data_obj_keys_sorted = this.sort_fn(this.data_obj);
if (this.sort_reverse) {
this.data_obj_keys_sorted = this.data_obj_keys_sorted.reverse();
}
}
async filterDataDefault(filter_str) {
throw new NotImplementedError();
}
}
class ExtraNetworksClusterizeTreeList extends ExtraNetworksClusterize {
selected_div_id = null;
constructor(args) {
args.no_data_text = "Directory is empty.";
super(args);
}
clear() {
this.selected_div_id = null;
super.clear();
}
getBoxShadow(depth) {
/** Generates style for a multi-level box shadow for vertical indentation lines.
* This is used to indicate the depth of a directory/file within a directory tree.
*/
let res = "";
var style = getComputedStyle(document.body);
let bg = style.getPropertyValue("--body-background-fill");
let fg = style.getPropertyValue("--border-color-primary");
let text_size = style.getPropertyValue("--button-large-text-size");
for (let i = 1; i <= depth; i++) {
res += `calc((${i} * ${text_size}) - (${text_size} * 0.6)) 0 0 ${bg} inset,`;
res += `calc((${i} * ${text_size}) - (${text_size} * 0.4)) 0 0 ${fg} inset`;
res += (i + 1 > depth) ? "" : ", ";
}
return res;
}
#setVisibility(div_id, visible) {
/** Recursively sets the visibility of a div_id and its children. */
for (const child_id of this.data_obj[div_id].children) {
this.data_obj[child_id].visible = visible;
if (visible) {
if (this.data_obj[div_id].expanded) {
this.#setVisibility(child_id, visible);
}
} else {
if (this.selected_div_id === child_id) {
this.selected_div_id = null;
}
this.#setVisibility(child_id, visible);
}
}
}
onRowSelected(div_id, elem, override) {
/** Selects a row and deselects all others. */
if (!isElementLogError(elem)) {
return;
}
if (!keyExistsLogError(this.data_obj, div_id)) {
return;
}
override = override === true;
if (!isNullOrUndefined(this.selected_div_id) && div_id !== this.selected_div_id) {
// deselect current selection if exists on page
const prev_elem = this.content_elem.querySelector(`div[data-div-id="${this.selected_div_id}"]`);
if (isElement(prev_elem)) {
delete prev_elem.dataset.selected;
}
}
elem.toggleAttribute("data-selected");
this.selected_div_id = "selected" in elem.dataset ? div_id : null;
}
async onRowExpandClick(div_id, elem) {
/** Expands or collapses a row to show/hide children. */
if (!keyExistsLogError(this.data_obj, div_id)){
return;
}
// Toggle state
this.data_obj[div_id].expanded = !this.data_obj[div_id].expanded;
const visible = this.data_obj[div_id].expanded;
for (const child_id of this.data_obj[div_id].children) {
this.#setVisibility(child_id, visible)
}
this.#setVisibility()
await this.setMaxItems(Object.values(this.data_obj).filter(v => v.visible).length);
}
async initDataDefault() {
/*Expects an object like the following:
{
parent: null or div_id,
children: array of div_id's,
visible: bool,
expanded: bool,
}
*/
console.log("BLAH:", this.options.callbacks.initData);
this.data_obj = await this.options.callbacks.initData(this.constructor.name);
}
async fetchDataDefault(idx_start, idx_end) {
const n_items = idx_end - idx_start;
const div_ids = [];
for (const div_id of this.data_obj_keys_sorted.slice(idx_start)) {
if (this.data_obj[div_id].visible) {
div_ids.push(div_id);
}
if (div_ids.length >= n_items) {
break;
}
}
const data = await this.options.callbacks.fetchData(
this.constructor.name,
this.extra_networks_tabname,
div_ids,
);
// we have to calculate the box shadows here since the element is on the page
// at this point and we can get its computed styles.
const style = getComputedStyle(document.body);
const text_size = style.getPropertyValue("--button-large-text-size");
const res = [];
for (const [div_id, item] of Object.entries(data)) {
const parsed_html = htmlStringToElement(item);
const depth = Number(parsed_html.dataset.depth);
parsed_html.style.paddingLeft = `calc(${depth} * ${text_size})`;
parsed_html.style.boxShadow = this.getBoxShadow(depth);
if (this.data_obj[div_id].expanded) {
parsed_html.dataset.expanded = "";
}
if (div_id === this.selected_div_id) {
parsed_html.dataset.selected = "";
}
res.push(parsed_html.outerHTML);
}
return rows;
}
async sortDataDefault(sort_mode, sort_dir) {
throw new NotImplementedError();
}
async filterDataDefault(filter_str) {
// just return the number of visible objects in our data.
return Object.values(this.data_obj).filter(v => v.visible).length;
}
}
class ExtraNetworksClusterizeCardsList extends ExtraNetworksClusterize {
constructor(args) {
args.no_data_text = "No files matching filter.";
super(args);
}
sortByName(data) {
return Object.keys(data).sort((a, b) => {
return STR_COLLATOR.compare(data[a].sort_name, data[b].sort_name);
});
}
sortByPath(data) {
return Object.keys(data).sort((a, b) => {
return STR_COLLATOR.compare(data[a].sort_path, data[b].sort_path);
});
}
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 initDataDefault() {
/*Expects an object like the following:
{
search_keys: array of strings,
sort_<mode>: string, (for various sort modes)
}
*/
console.log("HERE:", this.options.callbacks);
this.data_obj = await this.options.callbacks.initData(this.constructor.name);
}
async fetchDataDefault(idx_start, idx_end) {
const n_items = idx_end - idx_start;
const div_ids = [];
for (const div_id of this.data_obj_keys_sorted.slice(idx_start)) {
if (this.data_obj[div_id].visible) {
div_ids.push(div_id);
}
if (div_ids.length >= n_items) {
break;
}
}
const data = await this.options.callbacks.fetchData(
this.constructor.name,
this.extra_networks_tabname,
div_ids,
);
return Object.values(data);
}
async sortDataDefault(sort_mode_str, sort_dir_str) {
switch (sort_mode_str) {
case "name":
this.sort_fn = this.sortByName;
break;
case "path":
this.sort_fn = this.sortByPath;
break;
case "created":
this.sort_fn = this.sortByDateCreated;
break;
case "modified":
this.sort_fn = this.sortByDateModified;
break;
default:
this.sort_fn = this.default_sort_fn;
break;
}
await super.sortDataDefault(sort_mode_str, sort_dir_str)
}
async filterDataDefault(filter_str) {
/** Filters data by a string and returns number of items after filter. */
if (isString(filter_str)) {
this.filter_str = filter_str.toLowerCase();
} else if (isNullOrUndefined(this.filter_str)) {
this.filter_str = this.default_filter_str;
}
let n_visible = 0;
for (const [div_id, v] of Object.entries(this.data_obj)) {
let visible = v.search_terms.indexOf(this.filter_str) != -1;
if (v.search_only && this.filter_str.length < 4) {
visible = false;
}
this.data_obj[div_id].visible = visible;
if (visible) {
n_visible++;
}
}
return n_visible;
}
}

View file

@ -1,942 +0,0 @@
// Prevent eslint errors on functions defined in other files.
/*global
isString,
isStringLogError,
isNull,
isUndefined,
isNullOrUndefined,
isNullOrUndefinedLogError,
isElement,
isElementLogError,
isFunction,
isFunctionLogError,
getElementByIdLogError,
querySelectorLogError,
waitForElement,
Clusterize
*/
/*eslint no-undef: "error"*/
const JSON_UPDATE_DEBOUNCE_TIME_MS = 250;
const RESIZE_DEBOUNCE_TIME_MS = 250;
// Collators used for sorting.
const INT_COLLATOR = new Intl.Collator([], {numeric: true});
const STR_COLLATOR = new Intl.Collator("en", {numeric: true, sensitivity: "base"});
class InvalidCompressedJsonDataError extends Error {
constructor(message, options) {
super(message, options);
}
}
async function decompress(base64string) {
/** Decompresses a base64 encoded ZLIB compressed string. */
try {
if (isNullOrUndefined(base64string) || base64string === "") {
throw new Error("invalid base64 string");
}
const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter();
const bytes = Uint8Array.from(atob(base64string), c => c.charCodeAt(0));
writer.write(bytes);
writer.close();
const arrayBuffer = await new Response(ds.readable).arrayBuffer();
return await JSON.parse(new TextDecoder().decode(arrayBuffer));
} catch (error) {
throw new InvalidCompressedJsonDataError(error);
}
}
class ExtraNetworksClusterize {
/** Base class for a clusterize list. Cannot be used directly. */
constructor(
{
tabname,
extra_networks_tabname,
scroll_id,
content_id,
data_request_callback,
rows_in_block = 10,
blocks_in_cluster = 4,
show_no_data_row = true,
callbacks = {},
} = {
rows_in_block: 10,
blocks_in_cluster: 4,
show_no_data_row: true,
callbacks: {},
}
) {
// Do not continue if any of the required parameters are invalid.
if (!isStringLogError(tabname)) {
return;
}
if (!isStringLogError(extra_networks_tabname)) {
return;
}
if (!isStringLogError(scroll_id)) {
return;
}
if (!isStringLogError(content_id)) {
return;
}
if (!isFunctionLogError(data_request_callback)) {
return;
}
this.tabname = tabname;
this.extra_networks_tabname = extra_networks_tabname;
this.scroll_id = scroll_id;
this.content_id = content_id;
this.data_request_callback = data_request_callback;
this.rows_in_block = rows_in_block;
this.blocks_in_cluster = blocks_in_cluster;
this.show_no_data_row = show_no_data_row;
this.callbacks = callbacks;
this.clusterize = null;
this.scroll_elem = null;
this.content_elem = null;
this.element_observer = null;
this.resize_observer = null;
this.resize_observer_timer = null;
// Used to control logic. Many functions immediately return when disabled.
this.enabled = false;
// Stores the current encoded string so we can compare against future versions.
this.encoded_str = "";
this.no_data_text = "No results.";
this.no_data_class = "clusterize-no-data";
this.n_rows = this.rows_in_block;
this.n_cols = 1;
this.data_obj = {};
this.data_obj_keys_sorted = [];
this.sort_fn = this.sortByDivId;
this.sort_reverse = false;
}
reset() {
/** Destroy clusterize instance and set all instance variables to defaults. */
this.destroy();
this.teardownElementObservers();
this.teardownResizeObservers();
this.clusterize = null;
this.scroll_elem = null;
this.content_elem = null;
this.enabled = false;
this.encoded_str = "";
this.n_rows = this.rows_in_block;
this.n_cols = 1;
this.data_obj = {};
this.data_obj_keys_sorted = [];
this.sort_fn = this.sortByDivId;
this.sort_reverse = false;
}
async setup() {
return new Promise(resolve => {
// Setup our event handlers only after our elements exist in DOM.
Promise.all([
waitForElement(`#${this.scroll_id}`).then((elem) => this.scroll_elem = elem),
waitForElement(`#${this.content_id}`).then((elem) => this.content_elem = elem),
]).then(() => {
this.setupElementObservers();
this.setupResizeObservers();
return this.fetchData();
}).then(encoded_str => {
if (isNullOrUndefined(encoded_str)) {
// no new data to load. break from chain.
return resolve();
}
return this.parseJson(encoded_str);
}).then(json => {
if (isNullOrUndefined(json)) {
return resolve();
}
this.clear();
this.updateJson(json);
}).then(() => {
this.sortData();
}).then(() => {
// since calculateDims manually adds an element from our data_obj,
// we don't need clusterize initialzied to calculate dims.
this.calculateDims();
this.applyFilter();
this.rebuild(this.getFilteredRows());
return resolve();
}).catch(error => {
console.error("setup:: error in promise:", error);
return resolve();
});
});
}
async load() {
return new Promise(resolve => {
if (isNullOrUndefined(this.clusterize)) {
// This occurs whenever we click on a tab before initialization and setup
// have fully completed for this instance.
return resolve(this.setup());
}
if (this.calculateDims()) {
// Since dimensions updated, we need to apply the filter and rebuild.
this.applyFilter();
this.rebuild(this.getFilteredRows());
} else {
this.refresh(true);
}
return resolve();
});
}
async fetchData() {
let encoded_str = await this.data_request_callback(
this.tabname,
this.extra_networks_tabname,
this.constructor.name,
);
if (this.encoded_str === encoded_str) {
// no change to the data since last call. ignore.
return null;
}
this.encoded_str = encoded_str;
return this.encoded_str;
}
async parseJson(encoded_str) { /** promise */
/** Parses a base64 encoded and gzipped JSON string and sets up a clusterize instance. */
return new Promise((resolve, reject) => {
Promise.resolve(encoded_str)
.then(v => decompress(v))
.then(v => resolve(v))
.catch(error => {
return reject(error);
});
});
}
calculateDims() {
let res = false;
// Cannot calculate dims if not enabled since our elements won't be visible.
if (!this.enabled) {
return res;
}
// Cannot do anything if we have no data.
if (this.data_obj_keys_sorted.length <= 0) {
return res;
}
// Repair before anything else so we can actually get dimensions.
this.repair();
// Add an element to the container manually so we can calculate dims.
const child = htmlStringToElement(this.data_obj[this.data_obj_keys_sorted[0]].html);
this.content_elem.prepend(child);
let n_cols = calcColsPerRow(this.content_elem, child);
let n_rows = calcRowsPerCol(this.scroll_elem, child);
n_cols = (isNaN(n_cols) || n_cols <= 0) ? 1 : n_cols;
n_rows = (isNaN(n_rows) || n_rows <= 0) ? 1 : n_rows;
n_rows += 2;
if (n_cols != this.n_cols || n_rows != this.n_rows) {
// Sizes have changed. Update the instance values.
this.n_cols = n_cols;
this.n_rows = n_rows;
this.rows_in_block = this.n_rows;
res = true;
}
// Remove the temporary element from DOM.
child.remove();
return res;
}
sortData() {
/** Sorts the rows using the instance's `sort_fn`.
*
* It is expected that a subclass will override this function to update the
* instance's `sort_fn` then call `super.sortData()` to apply the sorting.
*/
this.sort_fn();
if (this.sort_reverse) {
this.data_obj_keys_sorted = this.data_obj_keys_sorted.reverse();
}
}
applyFilter() {
/** Should be overridden by child class. */
this.sortData();
if (!isNullOrUndefined(this.clusterize)) {
this.update(this.getFilteredRows());
}
//this.rebuild(this.getFilteredRows());
}
getFilteredRows() {
let rows = [];
let active_keys = this.data_obj_keys_sorted.filter(k => this.data_obj[k].active);
for (let i = 0; i < active_keys.length; i += this.n_cols) {
rows.push(
active_keys.slice(i, i + this.n_cols)
.map(k => this.data_obj[k].html)
.join("")
);
}
return rows;
}
rebuild(rows) {
if (!isNullOrUndefined(this.clusterize)) {
this.clusterize.destroy(true);
this.clusterize = null;
}
if (isNullOrUndefined(rows) || !Array.isArray(rows)) {
rows = [];
}
this.clusterize = new Clusterize(
{
rows: rows,
scrollId: this.scroll_id,
contentId: this.content_id,
rows_in_block: this.rows_in_block,
tag: "div",
blocks_in_cluster: this.blocks_in_cluster,
show_no_data_row: this.show_no_data_row,
no_data_text: this.no_data_text,
no_data_class: this.no_data_class,
callbacks: this.callbacks,
}
);
}
enable(enabled) {
/** Enables or disables this instance. */
// All values other than `true` for `enabled` result in this.enabled=false.
this.enabled = !(enabled !== true);
}
updateJson(json) { /** promise */
console.error("Base class method called. Must be overridden by subclass.");
return new Promise(resolve => {
return resolve();
});
}
sortByDivId() {
/** Sort data_obj keys (div_id) as numbers. */
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort((a, b) => INT_COLLATOR.compare(a, b));
}
updateDivContent(div_id, content) {
/** Updates an element's html in the dataset.
*
* NOTE: This function only updates the dataset. Calling function must call
* rebuild() to apply these changes to the view. Adding this call to this
* function would be very slow in the case where many divs need their content
* updated at the same time.
*/
if (!(div_id in this.data_obj)) {
console.error("div_id not in data_obj:", div_id);
} else if (isElement(content)) {
this.data_obj[div_id].html = content.outerHTML;
return true;
} else if (isString(content)) {
this.data_obj[div_id].html = content;
return true;
} else {
console.error("Invalid content:", div_id, content);
}
return false;
}
getMaxRowWidth() {
console.error("getMaxRowWidth:: Not implemented in base class. Must be overridden.");
return;
}
repair() {
/** Fixes element association in DOM. Returns whether a fix was performed. */
if (!this.enabled) {
return false;
}
if (!isElement(this.scroll_elem) || !isElement(this.content_elem)) {
return false;
}
// If association for elements is broken, replace them with instance version.
if (!this.scroll_elem.isConnected || !this.content_elem.isConnected) {
gradioApp().getElementById(this.scroll_id).replaceWith(this.scroll_elem);
// Fix resize observers since they are bound to each element individually.
if (!isNullOrUndefined(this.resize_observer)) {
this.resize_observer.disconnect();
this.resize_observer.observe(this.scroll_elem);
this.resize_observer.observe(this.content_elem);
}
// Make sure to refresh forcefully after updating the dom.
this.refresh(true);
return true;
}
return false;
}
onResize(elem_id) {
/** Callback whenever one of our visible elements is resized. */
if (!this.enabled) {
return;
}
this.refresh(true);
if (this.calculateDims()) {
this.rebuild(this.getFilteredRows());
}
}
onElementDetached(elem_id) {
/** Callback whenever one of our elements has become detached from the DOM. */
switch (elem_id) {
case this.scroll_id:
this.repair();
break;
case this.content_id:
this.repair();
break;
default:
break;
}
}
setupElementObservers() {
/** Listens for changes to the data, scroll, and content elements.
*
* During testing, the scroll/content elements would frequently get removed from
* the DOM. Our clusterize 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.
*
* We also use an observer to detect whenever the data element gets a new set
* of JSON data so that we can update our dataset.
*/
this.element_observer = new MutationObserver((mutations) => {
// don't waste time if this object isn't enabled.
if (!this.enabled) {
return;
}
let scroll_elem = gradioApp().getElementById(this.scroll_id);
if (scroll_elem && scroll_elem !== this.scroll_elem) {
this.onElementDetached(scroll_elem.id);
}
let content_elem = gradioApp().getElementById(this.content_id);
if (content_elem && content_elem !== this.content_elem) {
this.onElementDetached(content_elem.id);
}
});
this.element_observer.observe(gradioApp(), {subtree: true, childList: true, attributes: true});
}
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(entry.id), RESIZE_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_timer = null;
}
this.resize_observer = null;
this.resize_observer_timer = null;
}
/* ==== Clusterize.Js FUNCTION WRAPPERS ==== */
update(rows) {
/** Updates the clusterize rows. */
if (isNullOrUndefined(rows) || !Array.isArray(rows)) {
rows = this.getFilteredRows();
}
this.clusterize.update(rows);
}
clear() {
/** Clears the clusterize list and this instance's data. */
if (!isNullOrUndefined(this.clusterize)) {
this.clusterize.clear();
this.data_obj = {};
this.data_obj_keys_sorted = [];
if (isElement(this.content_elem)) {
this.content_elem.innerHTML = "<div class='clusterize-no-data'>Loading...</div>";
}
}
}
refresh(force) {
/** Refreshes the clusterize instance so that it can recalculate its dims.
* `force` [boolean]: If true, tells clusterize to refresh regardless of whether
* its dimensions have changed.
*/
if (isNullOrUndefined(this.clusterize)) {
return;
}
// Only allow boolean variables. default to false.
if (force !== true) {
force = false;
}
this.clusterize.refresh(force);
}
rowCount() {
/** Gets the total (not only visible) row count in the clusterize instance. */
return this.clusterize.getRowsAmount();
}
destroy() {
/** Destroys a clusterize instance and removes its rows from the page. */
// Passing `true` prevents clusterize from dumping every row in its dataset
// to the DOM. This kills performance so we never want to do this.
if (!isNullOrUndefined(this.clusterize)) {
this.clusterize.destroy(true);
this.clusterize = null;
}
this.data_obj = {};
this.data_obj_keys_sorted = [];
if (isElement(this.content_elem)) {
this.content_elem.innerHTML = "<div class='clusterize-no-data'>Loading...</div>";
}
}
}
class ExtraNetworksClusterizeTreeList extends ExtraNetworksClusterize {
/** Subclass used to display a directories/files in the Tree View. */
constructor(...args) {
super(...args);
this.no_data_text = "No directories/files";
this.selected_div_id = null;
}
reset() {
this.selected_div_id = null;
super.reset();
}
getBoxShadow(depth) {
/** Generates style for a multi-level box shadow for vertical indentation lines.
* This is used to indicate the depth of a directory/file within a directory tree.
*/
let res = "";
var style = getComputedStyle(document.body);
let bg = style.getPropertyValue("--body-background-fill");
let fg = style.getPropertyValue("--border-color-primary");
let text_size = style.getPropertyValue("--button-large-text-size");
for (let i = 1; i <= depth; i++) {
res += `calc((${i} * ${text_size}) - (${text_size} * 0.6)) 0 0 ${bg} inset,`;
res += `calc((${i} * ${text_size}) - (${text_size} * 0.4)) 0 0 ${fg} inset`;
res += (i + 1 > depth) ? "" : ", ";
}
return res;
}
updateJson(json) {
/** Processes JSON object and adds each entry to our data object. */
return new Promise(resolve => {
var style = getComputedStyle(document.body);
let text_size = style.getPropertyValue("--button-large-text-size");
for (const [k, v] of Object.entries(json)) {
let div_id = k;
let parsed_html = htmlStringToElement(v);
// parent_id = -1 if item is at root level
let parent_id = "parentId" in parsed_html.dataset ? parsed_html.dataset.parentId : -1;
let expanded = "expanded" in parsed_html.dataset;
let selected = "selected" in parsed_html.dataset;
let depth = Number(parsed_html.dataset.depth);
parsed_html.style.paddingLeft = `calc(${depth} * ${text_size})`;
parsed_html.style.boxShadow = this.getBoxShadow(depth);
// Add the updated html to the data object.
this.data_obj[div_id] = {
html: parsed_html.outerHTML,
active: parent_id === -1, // always show root
expanded: expanded || (parent_id === -1), // always expand root
selected: selected,
parent: parent_id,
children: [], // populated later
};
// maybe not necessary.
parsed_html = null;
}
// Build list of children for each element in dataset.
for (const [k, v] of Object.entries(this.data_obj)) {
if (v.parent === -1) {
continue;
} else if (!(v.parent in this.data_obj)) {
console.error("parent not in data:", v.parent);
} else {
this.data_obj[v.parent].children.push(k);
}
}
// Handle expanding of rows on initial load
for (const [k, v] of Object.entries(this.data_obj)) {
if (v.parent === -1) {
// Always show root level.
this.data_obj[k].active = true;
} else if (this.data_obj[v.parent].expanded && this.data_obj[v.parent].active) {
// Parent is both active and expanded. show child
this.data_obj[k].active = true;
} else {
this.data_obj[k].active = false;
}
}
return resolve();
});
}
removeChildRows(div_id) {
/** Removes rows from the list that are children of the passed div.
* The rows aren't removed from the data object, just set to active=false
* so they aren't displayed.
*/
for (const child_id of this.data_obj[div_id].children) {
this.data_obj[child_id].active = false;
if (this.data_obj[child_id].selected) {
// deselect the child only if it is selected.
let elem = htmlStringToElement(this.data_obj[child_id].html);
delete elem.dataset.selected;
this.data_obj[child_id].selected = false;
this.updateDivContent(child_id, elem);
}
this.removeChildRows(child_id);
}
}
addChildRows(div_id) {
/** Adds rows to the list that are children of the passed div.
* The rows aren't added to the data object, just set to active=true
* so they are displayed.
*/
for (const child_id of this.data_obj[div_id].children) {
this.data_obj[child_id].active = true;
if (this.data_obj[child_id].expanded) {
this.addChildRows(child_id);
}
}
}
onRowExpandClick(div_id, elem) {
/** Toggles expand/collapse of a row's children. */
if ("expanded" in elem.dataset) {
this.data_obj[div_id].expanded = false;
delete elem.dataset.expanded;
this.removeChildRows(div_id);
} else {
this.data_obj[div_id].expanded = true;
elem.dataset.expanded = "";
this.addChildRows(div_id);
}
this.updateDivContent(div_id, elem);
if (this.calculateDims()) {
this.rebuild(this.getFilteredRows());
} else {
this.update(this.getFilteredRows());
}
}
_setRowSelectedState(div_id, elem, new_state) {
if (new_state) {
elem.dataset.selected = "";
} else {
delete elem.dataset.selected;
}
this.data_obj[div_id].selected = new_state;
this.updateDivContent(div_id, elem);
}
onRowSelected(div_id, elem, override) {
/** Selects a row and deselects all others. */
if (!isElementLogError(elem)) {
return;
}
if (!(div_id in this.data_obj)) {
console.error("div_id not in dataset:", div_id);
return;
}
if (!isNullOrUndefined(override)) {
override = (override === true);
}
if (!isNullOrUndefined(this.selected_div_id) && div_id !== this.selected_div_id) {
// deselect the current selected row
let prev_elem = htmlStringToElement(this.data_obj[this.selected_div_id].html);
this._setRowSelectedState(this.selected_div_id, prev_elem, false);
// select the new row
this._setRowSelectedState(div_id, elem, true);
this.selected_div_id = div_id;
} else {
// toggle the passed row's selected state.
if (this.data_obj[div_id].selected) {
this._setRowSelectedState(div_id, elem, false);
this.selected_div_id = null;
} else {
this._setRowSelectedState(div_id, elem, true);
this.selected_div_id = div_id;
}
}
this.update(this.getFilteredRows());
}
getMaxRowWidth() {
/** Calculates the width of the widest row in the list. */
if (!this.enabled) {
// Inactive list is not displayed on screen. Can't calculate size.
return false;
}
if (this.rowCount() === 0) {
// If there is no data then just skip.
return false;
}
let max_width = 0;
for (let i = 0; i < this.content_elem.children.length; i += this.n_cols) {
let row_width = 0;
for (let j = 0; j < this.n_cols; j++) {
const child = this.content_elem.children[i + j];
const child_style = window.getComputedStyle(child, null);
const prev_style = child.style.cssText;
const n_cols = child_style.getPropertyValue("grid-template-columns").split(" ").length;
child.style.gridTemplateColumns = `repeat(${n_cols}, max-content)`;
row_width += child.scrollWidth;
// Restore previous style.
child.style.cssText = prev_style;
}
max_width = Math.max(max_width, row_width);
}
if (max_width <= 0) {
return;
}
// Adds the scroll element's border and the scrollbar's width to the result.
// If scrollbar isn't visible, then only the element border is added.
max_width += this.scroll_elem.offsetWidth - this.scroll_elem.clientWidth;
return max_width;
}
}
class ExtraNetworksClusterizeCardsList extends ExtraNetworksClusterize {
/** Subclass used to display cards in the Cards View. */
constructor(...args) {
super(...args);
this.no_data_text = "No files matching filter.";
this.default_sort_mode_str = "path";
this.default_sort_dir_str = "ascending";
this.default_filter_str = "";
this.sort_mode_str = this.default_sort_mode_str;
this.sort_dir_str = this.default_sort_dir_str;
this.filter_str = this.default_filter_str;
}
reset() {
this.sort_mode_str = this.default_sort_mode_str;
this.sort_dir_str = this.default_sort_dir_str;
this.filter_str = this.default_filter_str;
super.reset();
}
updateJson(json) {
/** Processes JSON object and adds each entry to our data object. */
return new Promise(resolve => {
this.data_obj = {};
for (const [i, [k, v]] of Object.entries(Object.entries(json))) {
let div_id = k;
let parsed_html = htmlStringToElement(v);
let search_only = isElement(parsed_html.querySelector(".search_only"));
let search_terms_elem = parsed_html.querySelector(".search_terms");
let search_terms = "";
if (isElement(search_terms_elem)) {
search_terms = Array.prototype.map.call(
parsed_html.querySelectorAll(".search_terms"),
(elem) => {
return elem.textContent.toLowerCase();
}
).join(" ");
}
// Add the updated html to the data object.
this.data_obj[div_id] = {
active: true,
html: v,
sort_name: parsed_html.dataset.sortName,
sort_path: parsed_html.dataset.sortPath,
sort_created: parsed_html.dataset.sortCreated,
sort_modified: parsed_html.dataset.sortModified,
search_only: search_only,
search_terms: search_terms,
};
// maybe not necessary
parsed_html = null;
}
return resolve();
});
}
sortByName() {
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort((a, b) => {
return STR_COLLATOR.compare(
this.data_obj[a].sort_name,
this.data_obj[b].sort_name,
);
});
}
sortByPath() {
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort((a, b) => {
return STR_COLLATOR.compare(
this.data_obj[a].sort_path,
this.data_obj[b].sort_path,
);
});
}
sortByCreated() {
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort((a, b) => {
return INT_COLLATOR.compare(
this.data_obj[a].sort_created,
this.data_obj[b].sort_created,
);
});
}
sortByModified() {
this.data_obj_keys_sorted = Object.keys(this.data_obj).sort((a, b) => {
return INT_COLLATOR.compare(
this.data_obj[a].sort_modified,
this.data_obj[b].sort_modified,
);
});
}
setSortMode(sort_mode_str) {
this.sort_mode_str = sort_mode_str;
}
setSortDir(sort_dir_str) {
this.sort_dir_str = sort_dir_str;
}
sortData() {
this.sort_reverse = this.sort_dir_str === "descending";
switch (this.sort_mode_str) {
case "name":
this.sort_fn = this.sortByName;
break;
case "path":
this.sort_fn = this.sortByPath;
break;
case "created":
this.sort_fn = this.sortByCreated;
break;
case "modified":
this.sort_fn = this.sortByModified;
break;
default:
this.sort_fn = this.sortByDivId;
break;
}
super.sortData();
}
applyFilter(filter_str) {
/** Filters our data object by setting each member's `active` attribute then sorts the result. */
if (!isNullOrUndefined(filter_str)) {
this.filter_str = filter_str.toLowerCase();
} else if (isNullOrUndefined(this.filter_str)) {
this.filter_str = this.default_filter_str;
}
for (const [k, v] of Object.entries(this.data_obj)) {
let visible = v.search_terms.indexOf(this.filter_str) != -1;
if (v.search_only && this.filter_str.length < 4) {
visible = false;
}
this.data_obj[k].active = visible;
}
super.applyFilter();
}
getMaxRowWidth() {
/** Calculates the width of the widest pseudo-row in the list. */
if (!this.enabled) {
// Inactive list is not displayed on screen. Can't calculate size.
return false;
}
if (this.rowCount() === 0) {
// If there is no data then just skip.
return false;
}
let max_width = 0;
for (let i = 0; i < this.content_elem.children.length; i += this.n_cols) {
let row_width = 0;
for (let j = 0; j < this.n_cols; j++) {
row_width += getComputedDims(this.content_elem.children[i + j]).width;
}
max_width = Math.max(max_width, row_width);
}
if (max_width <= 0) {
return;
}
// Add the container's padding to the result.
max_width += getComputedPaddingDims(this.content_elem).width;
// Add the scrollbar's width to the result. Will add 0 if scrollbar isnt present.
max_width += this.scroll_elem.offsetWidth - this.scroll_elem.clientWidth;
return max_width;
}
}

View file

@ -5,29 +5,29 @@ const isNumberLogError = x => {
if (isNumber(x)) {
return true;
}
console.error("expected number, got:", typeof x);
console.error(`expected number, got: ${typeof x}`);
return false;
}
};
const isNumberThrowError = x => {
if (isNumber(x)) {
return;
}
throw new Error("expected number, got:", typeof x);
}
throw new Error(`expected number, got: ${typeof x}`);
};
const isString = x => typeof x === "string" || x instanceof String;
const isStringLogError = x => {
if (isString(x)) {
return true;
}
console.error("expected string, got:", typeof x);
console.error(`expected string, got: ${typeof x}`);
return false;
};
const isStringThrowError = x => {
if (isString(x)) {
return;
}
throw new Error("expected string, got:", typeof x);
throw new Error(`expected string, got: ${typeof x}`);
};
const isNull = x => x === null;
@ -53,14 +53,14 @@ const isElementLogError = x => {
if (isElement(x)) {
return true;
}
console.error("expected element type, got:", typeof x);
console.error(`expected element type, got: ${typeof x}`);
return false;
};
const isElementThrowError = x => {
if (isElement(x)) {
return;
}
throw new Error("expected element type, got:", typeof x);
throw new Error(`expected element type, got: ${typeof x}`);
};
const isFunction = x => typeof x === "function";
@ -68,14 +68,62 @@ const isFunctionLogError = x => {
if (isFunction(x)) {
return true;
}
console.error("expected function type, got:", typeof x);
console.error(`expected function type, got: ${typeof x}`);
return false;
};
const isFunctionThrowError = x => {
if (isFunction(x)) {
return;
}
throw new Error("expected function type, got:", typeof x);
throw new Error(`expected function type, got: ${typeof x}`);
};
const isObject = x => typeof x === "object" && !Array.isArray(x);
const isObjectLogError = x => {
if (isObject(x)) {
return true;
}
console.error(`expected object type, got: ${typeof x}`);
return false;
};
const isObjectThrowError = x => {
if (isObject(x)) {
return;
}
throw new Error(`expected object type, got: ${typeof x}`);
};
const keyExists = (obj, k) => isObject(obj) && isString(k) && k in obj;
const keyExistsLogError = (obj, k) => {
if (keyExists(obj, k)) {
return true;
}
console.error(`key does not exist in object: ${k}`);
return false;
};
const keyExistsThrowError = (obj, k) => {
if (keyExists(obj, k)) {
return;
}
throw new Error(`key does not exist in object: ${k}`)
};
const 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;
};
const getValueLogError = (obj, k) => {
if (keyExistsLogError(obj, k)) {
return obj[k];
}
return null;
};
const getValueThrowError = (obj, k) => {
keyExistsThrowError(obj, k);
return obj[k];
};
const getElementByIdLogError = selector => {
@ -156,17 +204,17 @@ const getComputedDims = elem => {
};
};
const calcColsPerRow = function(parent, child) {
const calcColsPerRow = function (parent, child) {
/** Calculates the number of columns of children that can fit in a parent's visible width. */
const parent_inner_width = parent.offsetWidth - getComputedPaddingDims(parent).width;
return parseInt(parent_inner_width / getComputedDims(child).width);
return parseInt(parent_inner_width / getComputedDims(child).width, 10);
};
const calcRowsPerCol = function(parent, child) {
const calcRowsPerCol = function (parent, child) {
/** Calculates the number of rows of children that can fit in a parent's visible height. */
const parent_inner_height = parent.offsetHeight - getComputedPaddingDims(parent).height;
return parseInt(parent_inner_height / getComputedDims(child).height);
return parseInt(parent_inner_height / getComputedDims(child).height, 10);
};
/** Functions for asynchronous operations. */
@ -271,7 +319,7 @@ const waitForValueInObject = o => {
* Resolves when obj[k] == v
*/
return new Promise(resolve => {
waitForKeyInObject({k: o.k, obj: o.obj}).then(() => {
waitForKeyInObject({ k: o.k, obj: o.obj }).then(() => {
(function _waitForValueInObject() {
if (o.k in o.obj && o.obj[o.k] == o.v) {
@ -283,17 +331,93 @@ const waitForValueInObject = o => {
});
};
/** Requests */
const 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);
};
const requestGetPromise = (url, data) => {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
let args = Object.keys(data).map(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 {
resolve(xhr.responseText);
} catch (error) {
reject(error);
}
} else {
reject({ status: this.status, statusText: xhr.statusText });
}
}
};
xhr.send(JSON.stringify(data));
});
};
/** Misc helper functions. */
const clamp = (x, min, max) => Math.max(min, Math.min(x, max));
const getStyle = (prop, elem) => {
return window.getComputedStyle ? window.getComputedStyle(elem)[prop] : elem.currentStyle[prop];
}
};
const htmlStringToElement = function(str) {
const htmlStringToElement = function (str) {
/** Converts an HTML string into an Element type. */
let parser = new DOMParser();
let tmp = parser.parseFromString(str, "text/html");
return tmp.body.firstElementChild;
};
const 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));
}
};
const copyToClipboard = s => {
/** Copies the passed string to the clipboard. */
isStringThrowError(s);
navigator.clipboard.writeText(s);
};

View file

@ -4,12 +4,12 @@ import urllib.parse
from base64 import b64decode
from io import BytesIO
from pathlib import Path
from typing import Optional, Union
from typing import Optional, Union, Any, Callable
from dataclasses import dataclass
import zlib
import base64
import re
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.responses import Response, FileResponse, JSONResponse, PlainTextResponse
from modules import shared, ui_extra_networks_user_metadata, errors, extra_networks, util
from modules.images import read_info_from_image, save_image_with_geninfo
@ -40,6 +40,112 @@ class ExtraNetworksItem:
item: dict
class ListItem:
"""
Attributes:
id [str]: The ID of this list item.
html [str]: The HTML string for this item.
"""
def __init__(self, _id: str, _html: str) -> None:
self.id = _id
self.html = _html
class CardListItem(ListItem):
"""
Attributes:
visible [bool]: Whether the item should be shown in the list.
sort_keys [dict]: Nested dict where keys are sort modes and values are sort keys.
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.visible: bool = False
self.sort_keys = {}
self.search_terms = []
class TreeListItem(ListItem):
"""
Attributes:
visible [bool]: Whether the item should be shown in the list.
expanded [bool]: Whether the item children should be shown.
selected [bool]: Whether the item is selected by user.
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.node: Optional[DirectoryTreeNode] = None
self.visible: bool = False
self.expanded: bool = False
self.selected: bool = False
class DirectoryTreeNode:
def __init__(
self,
root_dir: str,
abspath: str,
parent: Optional["DirectoryTreeNode"] = None,
) -> None:
self.abspath = abspath
self.root_dir = root_dir
self.parent = parent
self.is_dir = False
self.item = None
self.relpath = os.path.relpath(self.abspath, self.root_dir)
self.children: list["DirectoryTreeNode"] = []
# If a parent is passed, then we add this instance to the parent's children.
if self.parent is not None:
self.parent.add_child(self)
def add_child(self, child: "DirectoryTreeNode") -> None:
self.children.append(child)
def build(self, items: dict[str, dict]) -> None:
"""Builds a tree of nodes as children of this instance.
Args:
items: A dictionary where keys are absolute filepaths for directories/files.
The values are dictionaries representing extra networks items.
"""
self.is_dir = os.path.isdir(self.abspath)
if self.is_dir:
for x in os.listdir(self.abspath):
child_path = os.path.join(self.abspath, x)
DirectoryTreeNode(self.root_dir, child_path, self).build(items)
else:
self.item = items.get(self.abspath, None)
def flatten(self, res: dict, dirs_only: bool = False) -> None:
"""Flattens the keys/values of the tree nodes into a dictionary.
Args:
res: The dictionary result updated in place. On initial call, should be passed
as an empty dictionary.
dirs_only: Whether to only add directories to the result.
Raises:
KeyError: If any nodes in the tree have the same ID.
"""
if self.abspath in res:
raise KeyError(f"duplicate key: {self.abspath}")
if not dirs_only or (dirs_only and self.is_dir):
res[self.abspath] = self
for child in self.children:
child.flatten(res, dirs_only)
def apply(self, fn: Callable) -> None:
"""Recursively calls passed function with instance for entire tree."""
fn(self)
for child in self.children:
child.apply(fn)
def get_tree(paths: Union[str, list[str]], items: dict[str, ExtraNetworksItem]) -> dict:
"""Recursively builds a directory tree.
@ -100,10 +206,15 @@ def register_page(page):
allowed_dirs.clear()
allowed_dirs.update(set(sum([x.allowed_directories_for_previews() for x in extra_pages], [])))
def get_page_by_name(extra_networks_tabname: str = "") -> "ExtraNetworksPage":
for page in extra_pages:
if page.extra_networks_tabname == extra_networks_tabname:
return page
raise HTTPException(status_code=404, detail=f"Page not found: {extra_networks_tabname}")
def fetch_file(filename: str = ""):
from starlette.responses import FileResponse
if not os.path.isfile(filename):
raise HTTPException(status_code=404, detail="File not found")
@ -118,12 +229,8 @@ def fetch_file(filename: str = ""):
return FileResponse(filename, headers={"Accept-Ranges": "bytes"})
def fetch_cover_images(page: str = "", item: str = "", index: int = 0):
from starlette.responses import Response
page = next(iter([x for x in extra_pages if x.name == page]), None)
if page is None:
raise HTTPException(status_code=404, detail="File not found")
def fetch_cover_images(extra_networks_tabname: str = "", item: str = "", index: int = 0):
page = get_page_by_name(extra_networks_tabname)
metadata = page.metadata.get(item)
if metadata is None:
@ -142,70 +249,82 @@ def fetch_cover_images(page: str = "", item: str = "", index: int = 0):
except Exception as err:
raise ValueError(f"File cannot be fetched: {item}. Failed to load cover image.") from err
def init_tree_data(tabname: str = "", extra_networks_tabname: str = "") -> JSONResponse:
page = get_page_by_name(extra_networks_tabname)
def get_list_data(
tabname: str = "",
data = page.generate_tree_view_data(tabname)
return JSONResponse(data, status_code=200)
def fetch_tree_data(
extra_networks_tabname: str = "",
list_type: Optional[str] = None,
) -> PlainTextResponse:
"""Responds to API GET requests on /sd_extra_networks/get-list-data with list data.
div_ids: Optional[list[str]] = None,
) -> JSONResponse:
page = get_page_by_name(extra_networks_tabname)
Args:
tabname: The primary tab name containing the data.
(i.e. txt2img, img2img)
extra_networks_tabname: The selected extra networks tabname.
(i.e. lora, hypernetworks, etc.)
list_type: The type of list data to retrieve. This reflects the
class name used in `extraNetworksClusterizeList.js`.
if div_ids is None:
return JSONResponse({})
Returns:
The string data result along with a status code.
A status_code of 501 is returned on error, 200 on success.
"""
res = ""
status_code = 200
res = {}
for div_id in div_ids:
if div_id in page.tree:
res[div_id] = page.tree[div_id].html
page = next(iter([
x for x in extra_pages
if x.extra_networks_tabname == extra_networks_tabname
]), None)
if page is None:
return PlainTextResponse(res, status_code=501)
if list_type == "ExtraNetworksClusterizeTreeList":
res = page.generate_tree_view_data(tabname)
elif list_type == "ExtraNetworksClusterizeCardsList":
res = page.generate_cards_view_data(tabname)
else:
status_code = 501 # HTTP_501_NOT_IMPLEMENTED
return PlainTextResponse(res, status_code=status_code)
return JSONResponse(res)
def get_metadata(page: str = "", item: str = "") -> JSONResponse:
page = next(iter([x for x in extra_pages if x.name == page]), None)
if page is None:
def fetch_cards_data(
extra_networks_tabname: str = "",
div_ids: Optional[list[str]] = None,
) -> JSONResponse:
page = get_page_by_name(extra_networks_tabname)
if div_ids is None:
return JSONResponse({})
res = {}
for div_id in div_ids:
if div_id in page.cards:
res[div_id] = page.cards[div_id].html
return JSONResponse(res)
def init_cards_data(tabname: str = "", extra_networks_tabname: str = "") -> JSONResponse:
page = get_page_by_name(extra_networks_tabname)
data = page.generate_cards_view_data(tabname)
return JSONResponse(data, status_code=200)
def get_metadata(extra_networks_tabname: str = "", item: str = "") -> JSONResponse:
try:
page = get_page_by_name(extra_networks_tabname)
except HTTPException:
return JSONResponse({})
metadata = page.metadata.get(item)
if metadata is None:
return JSONResponse({})
metadata = {i:metadata[i] for i in metadata if i != 'ssmd_cover_images'} # those are cover images, and they are too big to display in UI as text
# those are cover images, and they are too big to display in UI as text
metadata = {i: metadata[i] for i in metadata if i != 'ssmd_cover_images'}
return JSONResponse({"metadata": json.dumps(metadata, indent=4, ensure_ascii=False)})
def get_single_card(page: str = "", tabname: str = "", name: str = "") -> JSONResponse:
page = next(iter([x for x in extra_pages if x.name == page]), None)
def get_single_card(tabname: str = "", extra_networks_tabname: str = "", name: str = "") -> JSONResponse:
page = get_page_by_name(extra_networks_tabname)
try:
item = page.create_item(name, enable_filter=False)
page.items[name] = item
except Exception as e:
errors.display(e, "creating item for extra network")
item = page.items.get(name)
except Exception as exc:
errors.display(exc, "creating item for extra network")
item = page.items.get(name, None)
if item is None:
return JSONResponse({})
page.read_user_metadata(item, use_cache=False)
item_html = page.create_card_html(tabname=tabname, item=item)
@ -217,7 +336,11 @@ def add_pages_to_demo(app):
app.add_api_route("/sd_extra_networks/cover-images", fetch_cover_images, methods=["GET"])
app.add_api_route("/sd_extra_networks/metadata", get_metadata, methods=["GET"])
app.add_api_route("/sd_extra_networks/get-single-card", get_single_card, methods=["GET"])
app.add_api_route("/sd_extra_networks/get-list-data", get_list_data, methods=["GET"])
#app.add_api_route("/sd_extra_networks/init-tree-data", init_tree_data, methods=["GET"])
#app.add_api_route("/sd_extra_networks/init-cards-data", init_cards_data, methods=["GET"])
#app.add_api_route("/sd_extra_networks/fetch-tree-data", fetch_tree_data, methods=["GET"])
#app.add_api_route("/sd_extra_networks/fetch-cards-data", fetch_cards_data, methods=["GET"])
def quote_js(s):
@ -236,18 +359,26 @@ class ExtraNetworksPage:
self.allow_negative_prompt = False
self.metadata = {}
self.items = {}
self.cards = {}
self.tree = {}
self.tree_roots = {}
self.lister = util.MassFileLister()
# HTML Templates
self.pane_tpl = shared.html("extra-networks-pane.html")
self.pane_content_tree_tpl = shared.html("extra-networks-pane-tree.html")
self.pane_content_dirs_tpl = shared.html("extra-networks-pane-dirs.html")
self.card_tpl = shared.html("extra-networks-card.html")
self.tree_row_tpl = shared.html("extra-networks-tree-row.html")
self.btn_copy_path_tpl = shared.html("extra-networks-copy-path-button.html")
self.btn_metadata_tpl = shared.html("extra-networks-metadata-button.html")
self.btn_edit_item_tpl = shared.html("extra-networks-edit-item-button.html")
self.btn_dirs_view_tpl = shared.html("extra-networks-dirs-view-button.html")
self.btn_copy_path_tpl = shared.html("extra-networks-btn-copy-path.html")
self.btn_show_metadata_tpl = shared.html("extra-networks-btn-show-metadata.html")
self.btn_edit_metadata_tpl = shared.html("extra-networks-btn-edit-metadata.html")
self.btn_dirs_view_item_tpl = shared.html("extra-networks-btn-dirs-view-item.html")
# Sorted lists
# These just store ints so it won't use hardly any memory to just sort ahead
# of time for each sort mode. These are lists of keys for each file.
self.keys_sorted = {}
self.keys_by_name = []
self.keys_by_path = []
self.keys_by_created = []
self.keys_by_modified = []
def refresh(self):
pass
@ -281,8 +412,8 @@ class ExtraNetworksPage:
tabname: str,
label: str,
btn_type: str,
data_attributes: Optional[dict] = None,
btn_title: Optional[str] = None,
data_attributes: Optional[dict] = None,
dir_is_empty: bool = False,
item: Optional[dict] = None,
onclick_extra: Optional[str] = None,
@ -340,92 +471,6 @@ class ExtraNetworksPage:
res = re.sub(" +", " ", res.replace("\n", ""))
return res
def build_tree_html_dict(
self,
tree: dict,
res: dict,
tabname: str,
div_id: int,
depth: int,
parent_id: Optional[int] = None,
) -> int:
for k, v in sorted(tree.items(), key=lambda x: shared.natural_sort_key(x[0])):
if not isinstance(v, (ExtraNetworksItem,)):
# dir
if div_id in res:
raise KeyError("div_id already in res:", div_id)
dir_is_empty = True
for _v in v.values():
if shared.opts.extra_networks_tree_view_show_files:
dir_is_empty = False
break
elif not isinstance(_v, (ExtraNetworksItem,)):
dir_is_empty = False
break
else:
dir_is_empty = True
res[div_id] = self.build_tree_html_dict_row(
tabname=tabname,
label=os.path.basename(k),
btn_type="dir",
btn_title=k,
dir_is_empty=dir_is_empty,
data_attributes={
"data-div-id": div_id,
"data-parent-id": parent_id,
"data-tree-entry-type": "dir",
"data-depth": depth,
"data-path": k,
"data-expanded": parent_id is None, # Expand root directories
},
)
last_div_id = self.build_tree_html_dict(
tree=v,
res=res,
depth=depth + 1,
div_id=div_id + 1,
parent_id=div_id,
tabname=tabname,
)
div_id = last_div_id
else:
# file
if not shared.opts.extra_networks_tree_view_show_files:
# Don't add file if showing files is disabled in options.
continue
if div_id in res:
raise KeyError("div_id already in res:", div_id)
onclick = v.item.get("onclick", None)
if onclick is None:
# Don't quote prompt/neg_prompt since they are stored as js strings already.
onclick = html.escape(f"extraNetworksCardOnClick(event, '{tabname}');")
res[div_id] = self.build_tree_html_dict_row(
tabname=tabname,
label=html.escape(v.item.get("name", "").strip()),
btn_type="file",
data_attributes={
"data-div-id": div_id,
"data-parent-id": parent_id,
"data-tree-entry-type": "file",
"data-name": v.item.get("name", "").strip(),
"data-depth": depth,
"data-path": v.item.get("filename", "").strip(),
"data-hash": v.item.get("shorthash", None),
"data-prompt": v.item.get("prompt", "").strip(),
"data-neg-prompt": v.item.get("negative_prompt", "").strip(),
"data-allow-neg": self.allow_negative_prompt,
},
item=v.item,
onclick_extra=onclick,
)
div_id += 1
return div_id
def get_button_row(self, tabname: str, item: dict) -> str:
metadata = item.get("metadata", None)
name = item.get("name", "")
@ -434,14 +479,14 @@ class ExtraNetworksPage:
button_row_tpl = '<div class="button-row">{btn_copy_path}{btn_edit_item}{btn_metadata}</div>'
btn_copy_path = self.btn_copy_path_tpl.format(filename=filename)
btn_edit_item = self.btn_edit_item_tpl.format(
btn_edit_item = self.btn_edit_metadata_tpl.format(
tabname=tabname,
extra_networks_tabname=self.extra_networks_tabname,
name=name,
)
btn_metadata = ""
if metadata:
btn_metadata = self.btn_metadata_tpl.format(
btn_metadata = self.btn_show_metadata_tpl.format(
extra_networks_tabname=self.extra_networks_tabname,
name=name,
)
@ -457,7 +502,7 @@ class ExtraNetworksPage:
self,
tabname: str,
item: dict,
div_id: Optional[int] = None,
div_id: Optional[str] = None,
) -> str:
"""Generates HTML for a single ExtraNetworks Item.
@ -469,44 +514,37 @@ class ExtraNetworksPage:
Returns:
HTML string generated for this item. Can be empty if the item is not meant to be shown.
"""
preview = item.get("preview", None)
style_height = f"height: {shared.opts.extra_networks_card_height}px;" if shared.opts.extra_networks_card_height else ''
style_width = f"width: {shared.opts.extra_networks_card_width}px;" if shared.opts.extra_networks_card_width else ''
style_font_size = f"font-size: {shared.opts.extra_networks_card_text_scale*100}%;"
style = style_height + style_width + style_font_size
background_image = f'<img src="{html.escape(preview)}" class="preview" loading="lazy">' if preview else ''
style = f"font-size: {shared.opts.extra_networks_card_text_scale*100}%;"
if shared.opts.extra_networks_card_height:
style += f"height: {shared.opts.extra_networks_card_height}px;"
if shared.opts.extra_networks_card_width:
style += f"width: {shared.opts.extra_networks_card_width}px;"
background_image = None
preview = html.escape(item.get("preview", ""))
if preview:
background_image = f'<img src="{preview}" class="preview" loading="lazy">'
onclick = item.get("onclick", None)
if onclick is None:
# Don't quote prompt/neg_prompt since they are stored as js strings already.
onclick = html.escape(f"extraNetworksCardOnClick(event, '{tabname}');")
button_row = self.get_button_row(tabname, item)
local_path = ""
filename = item.get("filename", "")
for reldir in self.allowed_directories_for_previews():
absdir = os.path.abspath(reldir)
if filename.startswith(absdir):
local_path = filename[len(absdir):]
# if this is true, the item must not be shown in the default view, and must instead only be
# shown when searching for it
# if this is true, the item must not be shown in the default view,
# and must instead only be shown when searching for it
if shared.opts.extra_networks_hidden_models == "Always":
search_only = False
else:
search_only = "/." in local_path or "\\." in local_path
search_only = filename.startswith(".")
if search_only and shared.opts.extra_networks_hidden_models == "Never":
return ""
sort_keys = " ".join(
[
f'data-sort-{k}="{html.escape(str(v))}"'
for k, v in item.get("sort_keys", {}).items()
]
).strip()
sort_keys = {}
for sort_mode, sort_key in item.get("sort_keys", {}).items():
sort_keys[sort_mode.strip().lower()] = html.escape(str(sort_key))
search_terms_html = ""
search_terms_tpl = "<span class='hidden {class}'>{search_term}</span>"
@ -518,7 +556,10 @@ class ExtraNetworksPage:
}
)
description = (item.get("description", "") or "" if shared.opts.extra_networks_card_show_desc else "")
description = ""
if shared.opts.extra_networks_card_show_desc:
description = item.get("description", "")
if not shared.opts.extra_networks_card_description_is_html:
description = html.escape(description)
@ -530,6 +571,7 @@ class ExtraNetworksPage:
"data-prompt": item.get("prompt", "").strip(),
"data-neg-prompt": item.get("negative_prompt", "").strip(),
"data-allow-neg": self.allow_negative_prompt,
**{f"data-sort-{sort_mode}": sort_key for sort_mode, sort_key in sort_keys},
}
data_attributes_str = ""
@ -553,97 +595,163 @@ class ExtraNetworksPage:
description=description,
)
def generate_tree_view_data(self, tabname: str) -> str:
"""Generates tree view HTML as a base64 encoded zlib compressed json string."""
def generate_cards_view_data(self, tabname: str) -> dict:
for i, item in enumerate(self.items.values()):
div_id = str(i)
card_html = self.create_card_html(tabname=tabname, item=item, div_id=div_id)
sort_keys = {
k.strip().lower().replace(" ", "_"): html.escape(str(v))
for k, v in item.get("sort_keys", {}).items()
}
search_terms = item.get("search_terms", [])
self.cards[div_id] = CardListItem(div_id, card_html)
self.cards[div_id].sort_keys = sort_keys
self.cards[div_id].search_terms = search_terms
# Sort cards for all sort modes
sort_modes = ["name", "path", "date_created", "date_modified"]
for mode in sort_modes:
self.keys_sorted[mode] = sorted(
self.cards.keys(),
key=lambda k: shared.natural_sort_key(self.cards[k].sort_keys[mode]),
)
res = {}
for div_id, card_item in self.cards.items():
res[div_id] = {
**{f"sort_{mode}": key for mode, key in card_item.sort_keys.items()},
"search_terms": card_item.search_terms,
}
return res
def generate_tree_view_data(self, tabname: str) -> dict:
if not self.tree_roots:
return {}
# Flatten each root into a single dict
tree = {}
for node in self.tree_roots.values():
subtree = {}
node.flatten(subtree)
tree.update(subtree)
path_to_div_id = {}
div_id_to_node = {} # reverse mapping
# First assign div IDs to each node. Used for parent ID lookup later.
for i, path in enumerate(sorted(tree.keys(), key=shared.natural_sort_key)):
div_id = str(i)
path_to_div_id[path] = div_id
div_id_to_node[div_id] = tree[path]
show_files = shared.opts.extra_networks_tree_view_show_files is True
for div_id, node in div_id_to_node.items():
self.tree[div_id] = TreeListItem(div_id, "")
self.tree[div_id].node = node
parent_id = None
if node.parent is not None:
parent_id = path_to_div_id.get(node.parent.abspath, None)
if node.item is None: # directory
dir_is_empty = show_files and any(x.item is not None for x in node.children)
self.tree[div_id].html = self.build_tree_html_dict_row(
tabname=tabname,
label=os.path.basename(node.abspath),
btn_type="dir",
btn_title=node.abspath,
dir_is_empty=dir_is_empty,
data_attributes={
"data-div-id": div_id,
"data-parent-id": parent_id,
"data-tree-entry-type": "dir",
"data-depth": node.depth,
"data-path": node.abspath,
"data-expanded": node.parent is None, # Expand root directories
},
)
else: # file
if not show_files:
# Don't add file if files are disabled in the options.
continue
onclick = node.item.get("onclick", None)
if onclick is None:
onclick = html.escape(f"extraNetworksCardOnClick(event, '{tabname}');")
item_name = node.item.get("name", "").strip()
self.tree[div_id].html = self.build_tree_html_dict_row(
tabname=tabname,
label=html.escape(item_name),
btn_type="file",
data_attributes={
"data-div-id": div_id,
"data-parent-id": parent_id,
"data-tree-entry-type": "file",
"data-name": item_name,
"data-depth": node.depth,
"data-path": node.item.get("filename", "").strip(),
"data-hash": node.item.get("shorthash", None),
"data-prompt": node.item.get("prompt", "").strip(),
"data-neg-prompt": node.item.get("negative_prompt", "").strip(),
"data-allow-neg": self.allow_negative_prompt,
},
item=node.item,
onclick_extra=onclick,
)
res = {}
if self.tree:
self.build_tree_html_dict(
tree=self.tree,
res=res,
depth=0,
div_id=0,
parent_id=None,
tabname=tabname,
)
# Expand all root directories and set them to active so they are displayed.
for path in self.tree_roots.keys():
div_id = path_to_div_id[path]
self.tree[div_id].expanded = True
self.tree[div_id].visible = True
# Set all direct children to active
for child_node in self.tree[div_id].node.children:
self.tree[child_node.id].visible = True
return base64.b64encode(
zlib.compress(
json.dumps(res, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
)
).decode("utf-8")
for div_id, tree_item in self.tree.items():
# Expand root nodes and make them visible.
expanded = tree_item.node.parent is None
visible = tree_item.node.parent is None
parent = None
if tree_item.node.parent is not None:
parent = path_to_div_id[tree_item.node.parent.abspath]
# Direct children of root nodes should be visible by default.
if tree_item.node.parent.node is None:
visible = True
def generate_tree_view_data_div(self, tabname: str) -> str:
"""Generates HTML for displaying folders in a tree view.
res[div_id] = {
"parent": parent,
"children": [path_to_div_id[child.abspath] for child in tree_item.node.children],
"visible": visible,
"expanded": expanded,
}
return res
Args:
tabname: The name of the active tab.
Returns:
HTML string generated for this tree view.
"""
tpl = """<div id="{tabname_full}_tree_list_data"
class="extra-network-script-data"
data-tabname-full={tabname_full}
data-proxy-name=tree_list
data-json={data}
hidden></div>"""
return tpl.format(
tabname_full=f"{tabname}_{self.extra_networks_tabname}",
data=self.generate_tree_view_data(tabname),
)
def create_dirs_view_html(self, tabname: str) -> str:
"""Generates HTML for displaying folders."""
# Flatten each root into a single dict. Only get the directories for buttons.
tree = {}
for node in self.tree_roots.values():
subtree = {}
node.flatten(subtree, dirs_only=True)
tree.update(subtree)
def _get_dirs_buttons(tree: dict, res: list) -> None:
""" Builds a list of directory names from a tree. """
for k, v in sorted(tree.items(), key=lambda x: shared.natural_sort_key(x[0])):
if not isinstance(v, (ExtraNetworksItem,)):
# dir
res.append(k)
_get_dirs_buttons(tree=v, res=res)
dirs = []
_get_dirs_buttons(tree=self.tree, res=dirs)
# Sort the tree nodes by relative paths
dir_nodes = list(sorted(
tree.values(),
key=lambda x: shared.natural_sort_key(x.relpath),
))
dirs_html = "".join([
self.btn_dirs_view_tpl.format(**{
"extra_class": "search-all" if d == "" else "",
self.btn_dirs_view_item_tpl.format(**{
"extra_class": "search-all" if node.relpath == "" else "",
"tabname_full": f"{tabname}_{self.extra_networks_tabname}",
"path": html.escape(d),
}) for d in dirs
"path": html.escape(node.relpath),
}) for node in dir_nodes
])
return dirs_html
def generate_cards_view_data(self, tabname: str) -> str:
res = {}
for i, item in enumerate(self.items.values()):
res[i] = self.create_card_html(tabname=tabname, item=item, div_id=i)
return base64.b64encode(
zlib.compress(
json.dumps(res, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
)
).decode("utf-8")
def generate_cards_view_data_div(self, tabname: str, none_message: Optional[str]) -> str:
"""Generates HTML for the network Card View section for a tab.
This HTML goes into the `extra-networks-pane.html` <div> with
`id='{tabname}_{extra_networks_tabname}_cards`.
Args:
tabname: The name of the active tab.
none_message: HTML text to show when there are no cards.
Returns:
HTML formatted string.
"""
res = self.generate_cards_view_data(tabname)
return f'<div id="{tabname}_{self.extra_networks_tabname}_cards_list_data" class="extra-network-script-data" data-tabname-full={tabname}_{self.extra_networks_tabname} data-proxy-name=cards_list data-json={res} hidden></div>'
def create_html(self, tabname, *, empty=False):
"""Generates an HTML string for the current pane.
@ -672,10 +780,13 @@ class ExtraNetworksPage:
self.read_user_metadata(item)
# Setup the tree dictionary.
roots = self.allowed_directories_for_previews()
tree_items = {v["filename"]: ExtraNetworksItem(v) for v in self.items.values()}
tree = get_tree([os.path.abspath(x) for x in roots], items=tree_items)
self.tree = tree
tree_items = {v["filename"]: v for v in self.items.values()}
# Create a DirectoryTreeNode for each root directory since they might not share
# a common path.
for path in self.allowed_directories_for_previews():
abspath = os.path.abspath(path)
self.tree_roots[abspath] = DirectoryTreeNode(os.path.dirname(abspath), abspath, None)
self.tree_roots[abspath].build(tree_items)
# Generate the html for displaying directory buttons
dirs_html = self.create_dirs_view_html(tabname)
@ -744,7 +855,7 @@ class ExtraNetworksPage:
file = f"{path}.safetensors"
if self.lister.exists(file) and 'ssmd_cover_images' in metadata and len(list(filter(None, json.loads(metadata['ssmd_cover_images'])))) > 0:
return f"./sd_extra_networks/cover-images?page={self.extra_networks_tabname}&item={name}"
return f"./sd_extra_networks/cover-images?extra_networks_tabname={self.extra_networks_tabname}&item={name}"
return None
@ -839,13 +950,23 @@ def create_ui(interface: gr.Blocks, unrelated_tabs, tabname):
ui.preview_target_filename = gr.Textbox('Preview save filename', elem_id=f"{tabname}_preview_filename", visible=False)
for tab in unrelated_tabs:
tab.select(fn=None, _js=f"function(){{extraNetworksUnrelatedTabSelected('{tabname}');}}", inputs=[], outputs=[], show_progress=False)
tab.select(
fn=None,
_js=f"fujnction(){{extraNetworksUnrelatedTabSelected('{tabname}');}}",
inputs=[],
outputs=[],
show_progress=False,
)
for page, tab in zip(ui.stored_extra_pages, related_tabs):
jscode = (
"function(){"
f"extraNetworksTabSelected('{tabname}', '{tabname}_{page.extra_networks_tabname}_prompts', {str(page.allow_prompt).lower()}, {str(page.allow_negative_prompt).lower()}, '{tabname}_{page.extra_networks_tabname}');"
"}"
"function(){extraNetworksTabSelected("
f"'{tabname}', "
f"'{tabname}_{page.extra_networks_tabname}_prompts', "
f"{str(page.allow_prompt).lower()}, "
f"{str(page.allow_negative_prompt).lower()}, "
f"'{tabname}_{page.extra_networks_tabname}'"
");}"
)
tab.select(fn=None, _js=jscode, inputs=[], outputs=[], show_progress=False)