From 4e31bca922640155af09499d25927709ee854543 Mon Sep 17 00:00:00 2001 From: Sj-Si Date: Fri, 5 Apr 2024 08:40:49 -0400 Subject: [PATCH] Move utility functions to separate script file. Begin redesign of clusterizer. --- javascript/clusterize.js | 489 ++++++++++++++++++++++ javascript/clusterize.min.js | 18 - javascript/extraNetworks.js | 164 -------- javascript/extraNetworksClusterizeList.js | 74 ---- javascript/utils.js | 299 +++++++++++++ modules/ui_gradio_extensions.py | 9 +- 6 files changed, 796 insertions(+), 257 deletions(-) create mode 100644 javascript/clusterize.js delete mode 100644 javascript/clusterize.min.js create mode 100644 javascript/utils.js diff --git a/javascript/clusterize.js b/javascript/clusterize.js new file mode 100644 index 000000000..62f25ae2e --- /dev/null +++ b/javascript/clusterize.js @@ -0,0 +1,489 @@ +/* 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. +*/ + +const SCROLL_DEBOUNCE_TIME_MS = 50; +const RESIZE_OBSERVER_DEBOUNCE_TIME_MS = 100; +const ELEMENT_OBSERVER_DEBOUNCE_TIME_MS = 100; + +class Clusterize { + scroll_elem = null; + content_elem = null; + scroll_id = null; + content_id = null; + #options = {}; + #is_mac = null; + #ie = null; + #n_rows = null; + #cache = {}; + #scroll_top = 0; + #last_cluster = false; + #scroll_debounce = 0; + #resize_observer = null; + #resize_observer_timer = null; + #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: {} + }; + + const options = [ + 'rows_in_block', + 'blocks_in_cluster', + 'show_no_data_row', + 'no_data_class', + 'no_data_text', + 'keep_parity', + 'tag', + 'callbacks', + ]; + + // detect ie9 and lower + // https://gist.github.com/padolsey/527683#comment-786682 + this.#ie = (function () { + for (var v = 3, + el = document.createElement('b'), + all = el.all || []; + el.innerHTML = '', + 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.scroll_elem = args["scrollId"] ? document.getElementById(args["scrollId"]) : args["scrollElem"]; + if (!isElement(this.scroll_elem)) { + throw new Error("Error! Could not find scroll element"); + } + 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"); + } + this.content_id = this.content_elem.id; + + if (!this.content_elem.hasAttribute("tabindex")) { + this.content_elem.setAttribute("tabindex", 0); + } + + 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; + } + } + + // ==== PUBLIC FUNCTIONS ==== + async setup() { + await this.#insertToDOM(); + this.scroll_elem.scrollTop = this.#scroll_top; + + this.#setupEvent("scroll", this.scroll_elem, this.#onScroll); + this.#setupElementObservers(); + this.#setupResizeObservers(); + } + + destroy() { + this.#teardownEvent("scroll", this.scroll_elem, this.#onScroll); + this.#teardownElementObservers(); + this.#teardownResizeObservers(); + this.#html(this.#generateEmptyRow().join("")); + } + + refresh(force) { + if (this.#getRowsHeight() || force) { + this.update() + } + } + + async update() { + this.#scroll_top = this.scroll_elem.scrollTop; + // fixes #39 + if (this.#n_rows * this.#options.item_height < this.#scroll_top) { + this.scroll_elem.scrollTop = 0; + this.#last_cluster = 0; + } + + await this.#insertToDOM(); + this.scroll_elem.scrollTop = this.#scroll_top; + } + + getRowsAmount() { + return this.#n_rows; + } + + getScrollProgress() { + return this.#options.scroll_top / (this.#n_rows * this.#options.item_height) * 100 || 0; + } + + async filterData(filter) { + // 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; + this.refresh(true); + } + // Apply sort to the new filtered data. + await this.sortData(this.#sort_mode, this.#sort_dir); + } + + async sortData(mode, dir) { + // 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.#insertToDOM(); + } + + // ==== PRIVATE FUNCTIONS ==== + + #fetchDataDefault() { + return Promise.resolve([]); + } + + #sortDataDefault() { + return Promise.resolve([]); + } + + #filterDataDefault() { + return Promise.resolve([]); + } + + #exploreEnvironment(rows, cache) { + 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.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(); + } + this.#getRowsHeight(); + } + + #getRowsHeight() { + const prev_item_height = this.#options.item_height; + const prev_rows_in_block = this.#options.rows_in_block; + + this.#options.cluster_height = 0; + if (!this.#n_rows) { + return; + } + + const nodes = this.content_elem.children; + if (!nodes.length) { + return; + } + const node = nodes[Math.floor(nodes.length / 2)]; + this.#options.item_height = node.offsetHeight; + // 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; + } + // consider margins and margins collapsing + if (this.#options.tag !== "tr") { + const margin_top = parseInt(getStyle("marginTop", node), 10) || 0; + const margin_bottom = parseInt(getStyle("marginBottom", node), 10) || 0; + this.#options.item_height += Math.max(margin_top, margin_bottom); + } + + // 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.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; + } + + #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); + return Math.min(current_cluster, max_cluster); + } + + #generateEmptyRow() { + 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 td = document.createElement("td"); + // fixes #53 + td.colSpan = 100; + td.appendChild(no_data_content); + empty_row.appendChild(td); + } else { + empty_row.appendChild(no_data_content); + } + return [empty_row.outerHTML]; + } + + 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 this_cluster_rows = await this.#options.callbacks.fetchData(items_start, items_end); + return { + top_offset: top_offset, + bottom_offset: bottom_offset, + rows_above: rows_above, + rows: this_cluster_rows, + }; + } + + async #insertToDOM() { + if (!this.#options.cluster_height) { + 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(""); + 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); + const layout = []; + + if (this_cluster_content_changed || top_offset_changed) { + if (data.top_offset) { + 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.#html(layout.join("")); + 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(); + } else if (only_bottom_offset_changed) { + this.content_elem.lastChild.style.height = `${data.bottom_offset}px`; + } + } + + #html(data) { + const content_elem = this.content_elem; + if (this.#ie && this.#ie <= 9 && this.#options.tag === "tr") { + const div = document.createElement("div"); + let last; + div.innerHTML = `${data}
`; + while ((last = content_elem.lastChild)) { + content_elem.removeChild(last); + } + const rows_nodes = this.#getChildNodes(div.firstChild.firstChild); + while (rows_nodes.length) { + content_elem.appendChild(rows_nodes.shift()); + } + } else { + content_elem.innerHTML = data; + } + } + + #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(" "); + 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.#is_mac) { + if (!this.#pointer_events_set) { + this.content_elem.style.pointerEvents = "none"; + this.#pointer_events_set = true; + clearTimeout(this.#scroll_debounce); + this.#scroll_debounce = setTimeout(() => { + this.content_elem.style.pointerEvents = "auto"; + this.#pointer_events_set = false; + }, SCROLL_DEBOUNCE_TIME_MS); + } + } + if (this.#last_cluster !== (this.#last_cluster = this.#getClusterNum())) { + await this.#insertToDOM(); + } + if (this.#options.callbacks.scrollingProgress) { + this.#options.callbacks.scrollingProgress(this.getScrollingProgress()); + } + } + + async #onResize() { + await this.refresh(); + } + + #fixElementReferences() { + if (!isElement(this.scroll_elem) || !isElement(this.content_elem)) { + return; + } + + // If association for elements is broken, replace them with instance version. + if (!this.scroll_elem.isConnected || !this.content_elem.isConnected) { + document.getElementByid(this.scroll_id).replaceWith(this.scroll_elem); + // refresh since sizes may have changed. + this.refresh(true); + } + } + + #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, event => listener.call(this), false); + } else { + return elem.attachEvent(`on${type}`, event => listener.call(this)); + } + } + + #teardownEvent(type, elem, listener) { + if (elem.removeEventListener) { + return elem.removeEventListener(type, event => listener.call(this), false); + } else { + return elem.detachEvent(`on${type}`, event => listener.call(this)); + } + } +} diff --git a/javascript/clusterize.min.js b/javascript/clusterize.min.js deleted file mode 100644 index c0111d602..000000000 --- a/javascript/clusterize.min.js +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable */ -/* Clusterize.js - v0.19.0 - 2021-12-19 - http://NeXTs.github.com/Clusterize.js/ - Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */ - -;(function(p,m){"undefined"!=typeof module?module.exports=m():"function"==typeof define&&"object"==typeof define.amd?define(m):this[p]=m()})("Clusterize",function(){function p(b,a,c){return a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)}function m(b,a,c){return a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent("on"+b,c)}function t(b){return"[object Array]"===Object.prototype.toString.call(b)}function q(b,a){return window.getComputedStyle?window.getComputedStyle(a)[b]: -a.currentStyle[b]}var r=function(){for(var b=3,a=document.createElement("b"),c=a.all||[];a.innerHTML="\x3c!--[if gt IE "+ ++b+"]>=r&&!c.tag&&(c.tag=b[0].match(/<([^>\s/]*)/)[1].toLowerCase()),1>=this.content_elem.children.length&&(a.data=this.html(b[0]+b[0]+b[0])),c.tag||(c.tag=this.content_elem.children[0].tagName.toLowerCase()), -this.getRowsHeight(b))},getRowsHeight:function(b){var a=this.options,c=a.item_height;a.cluster_height=0;if(b.length&&(b=this.content_elem.children,b.length)){var d=b[Math.floor(b.length/2)];a.item_height=d.offsetHeight;"tr"==a.tag&&"collapse"!=q("borderCollapse",this.content_elem)&&(a.item_height+=parseInt(q("borderSpacing",this.content_elem),10)||0);"tr"!=a.tag&&(b=parseInt(q("marginTop",d),10)||0,d=parseInt(q("marginBottom",d),10)||0,a.item_height+=Math.max(b,d));a.block_height=a.item_height*a.rows_in_block; -a.rows_in_cluster=a.blocks_in_cluster*a.rows_in_block;a.cluster_height=a.blocks_in_cluster*a.block_height;return c!=a.item_height}},getClusterNum:function(b){var a=this.options;a.scroll_top=this.scroll_elem.scrollTop;var c=a.cluster_height-a.block_height;return Math.min(Math.floor(a.scroll_top/c),Math.floor(b.length*a.item_height/c))},generateEmptyRow:function(){var b=this.options;if(!b.tag||!b.show_no_data_row)return[];var a=document.createElement(b.tag),c=document.createTextNode(b.no_data_text); -a.className=b.no_data_class;if("tr"==b.tag){var d=document.createElement("td");d.colSpan=100;d.appendChild(c)}a.appendChild(d||c);return[a.outerHTML]},generate:function(b){var a=this.options,c=b.length;if(cg&&e++;d=r&&"tr"==this.options.tag){var c=document.createElement("div");for(c.innerHTML=""+b+"
";b=a.lastChild;)a.removeChild(b);for(c=this.getChildNodes(c.firstChild.firstChild);c.length;)a.appendChild(c.shift())}else a.innerHTML=b},getChildNodes:function(b){b=b.children;for(var a=[],c=0,d=b.length;c typeof x === "string" || x instanceof String; -const isStringLogError = x => { - if (isString(x)) { - return true; - } - console.error("expected string, got:", typeof x); - return false; -}; -const isNull = x => x === null; -const isUndefined = x => typeof x === "undefined" || x === undefined; -// checks both null and undefined for simplicity sake. -const isNullOrUndefined = x => isNull(x) || isUndefined(x); -const isNullOrUndefinedLogError = x => { - if (isNullOrUndefined(x)) { - console.error("Variable is null/undefined."); - return true; - } - return false; -}; - -const isElement = x => x instanceof Element; -const isElementLogError = x => { - if (isElement(x)) { - return true; - } - console.error("expected element type, got:", typeof x); - return false; -}; - -const isFunction = x => typeof x === "function"; -const isFunctionLogError = x => { - if (isFunction(x)) { - return true; - } - console.error("expected function type, got:", typeof x); - return false; -}; - -const getElementByIdLogError = selector => { - let elem = gradioApp().getElementById(selector); - isElementLogError(elem); - return elem; -}; - -const querySelectorLogError = selector => { - let elem = gradioApp().querySelector(selector); - isElementLogError(elem); - return elem; -}; - -const debounce = (handler, timeout_ms) => { - /** Debounces a function call. - * - * NOTE: This will NOT work if called from within a class. - * It will drop `this` from scope. - * - * Repeated calls to the debounce handler will not call the handler until there are - * no new calls to the debounce handler for timeout_ms time. - * - * Example: - * function add(x, y) { return x + y; } - * let debounce_handler = debounce(add, 5000); - * let res; - * for (let i = 0; i < 10; i++) { - * res = debounce_handler(i, 100); - * } - * console.log("Result:", res); - * - * This example will print "Result: 109". - */ - let timer = null; - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => handler(...args), timeout_ms); - }; -}; - -const waitForElement = selector => { - /** Promise that waits for an element to exist in DOM. */ - return new Promise(resolve => { - if (document.querySelector(selector)) { - return resolve(document.querySelector(selector)); - } - - const observer = new MutationObserver(mutations => { - if (document.querySelector(selector)) { - observer.disconnect(); - resolve(document.querySelector(selector)); - } - }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true - }); - }); -}; - -const waitForBool = o => { - /** Promise that waits for a boolean to be true. - * - * `o` must be an Object of the form: - * { state: } - * - * Resolves when (state === true) - */ - return new Promise(resolve => { - (function _waitForBool() { - if (o.state) { - return resolve(); - } - setTimeout(_waitForBool, 100); - })(); - }); -}; - -const waitForKeyInObject = o => { - /** Promise that waits for a key to exist in an object. - * - * `o` must be an Object of the form: - * { - * obj: , - * k: , - * } - * - * Resolves when (k in obj) - */ - return new Promise(resolve => { - (function _waitForKeyInObject() { - if (o.k in o.obj) { - return resolve(); - } - setTimeout(_waitForKeyInObject, 100); - })(); - }); -}; - -const waitForValueInObject = o => { - /** Promise that waits for a key value pair in an Object. - * - * `o` must be an Object of the form: - * { - * obj: , - * k: , - * v: - * } - * - * Resolves when obj[k] == v - */ - return new Promise(resolve => { - waitForKeyInObject({k: o.k, obj: o.obj}).then(() => { - (function _waitForValueInObject() { - - if (o.k in o.obj && o.obj[o.k] == o.v) { - return resolve(); - } - setTimeout(_waitForValueInObject, 100); - })(); - }); - }); -}; - function toggleCss(key, css, enable) { var style = document.getElementById(key); if (enable && !style) { diff --git a/javascript/extraNetworksClusterizeList.js b/javascript/extraNetworksClusterizeList.js index a1cd92f78..960f9bd54 100644 --- a/javascript/extraNetworksClusterizeList.js +++ b/javascript/extraNetworksClusterizeList.js @@ -29,60 +29,6 @@ class InvalidCompressedJsonDataError extends Error { } } -const getComputedPropertyDims = (elem, prop) => { - /** Returns the top/left/bottom/right float dimensions of an element for the specified property. */ - const style = window.getComputedStyle(elem, null); - 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`)), - }; -}; - -const 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, - }; -}; - -const 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, - }; -}; - -const 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--width instead. - const dims = getComputedPropertyDims(elem, "border"); - return { - width: dims.left + dims.right, - height: dims.top + dims.bottom, - }; -}; - -const 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, - }; -}; - async function decompress(base64string) { /** Decompresses a base64 encoded ZLIB compressed string. */ try { @@ -101,26 +47,6 @@ async function decompress(base64string) { } } -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 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); - -}; - -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); -}; - class ExtraNetworksClusterize { /** Base class for a clusterize list. Cannot be used directly. */ constructor( diff --git a/javascript/utils.js b/javascript/utils.js new file mode 100644 index 000000000..fe213d6b3 --- /dev/null +++ b/javascript/utils.js @@ -0,0 +1,299 @@ +/** Helper functions for checking types and simplifying logging/error handling. */ + +const isNumber = x => typeof x === "number" && isFinite(x); +const isNumberLogError = x => { + if (isNumber(x)) { + return true; + } + console.error("expected number, got:", typeof x); + return false; +} +const isNumberThrowError = x => { + if (isNumber(x)) { + return; + } + 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); + return false; +}; +const isStringThrowError = x => { + if (isString(x)) { + return; + } + throw new Error("expected string, got:", typeof x); +}; + +const isNull = x => x === null; +const isUndefined = x => typeof x === "undefined" || x === undefined; +// checks both null and undefined for simplicity sake. +const isNullOrUndefined = x => isNull(x) || isUndefined(x); +const isNullOrUndefinedLogError = x => { + if (isNullOrUndefined(x)) { + console.error("Variable is null/undefined."); + return true; + } + return false; +}; +const isNullOrUndefinedThrowError = x => { + if (!isNullOrUndefined(x)) { + return; + } + throw new Error("Variable is null/undefined."); +}; + +const isElement = x => x instanceof Element; +const isElementLogError = x => { + if (isElement(x)) { + return true; + } + 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); +}; + +const isFunction = x => typeof x === "function"; +const isFunctionLogError = x => { + if (isFunction(x)) { + return true; + } + 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); +}; + +const getElementByIdLogError = selector => { + const elem = gradioApp().getElementById(selector); + isElementLogError(elem); + return elem; +}; +const getElementByIdThrowError = selector => { + const elem = gradioApp().getElementById(selector); + isElementThrowError(elem); + return elem; +}; + +const querySelectorLogError = selector => { + const elem = gradioApp().querySelector(selector); + isElementLogError(elem); + return elem; +}; +const querySelectorThrowError = selector => { + const elem = gradioApp().querySelector(selector); + isElementThrowError(elem); + return elem; +}; + +/** Functions for getting dimensions of elements. */ + +const getComputedPropertyDims = (elem, prop) => { + /** Returns the top/left/bottom/right float dimensions of an element for the specified property. */ + const style = window.getComputedStyle(elem, null); + 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`)), + }; +}; + +const 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, + }; +}; + +const 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, + }; +}; + +const 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--width instead. + const dims = getComputedPropertyDims(elem, "border"); + return { + width: dims.left + dims.right, + height: dims.top + dims.bottom, + }; +}; + +const 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, + }; +}; + +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); + +}; + +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); +}; + +/** Functions for asynchronous operations. */ + +const debounce = (handler, timeout_ms) => { + /** Debounces a function call. + * + * NOTE: This will NOT work if called from within a class. + * It will drop `this` from scope. + * + * Repeated calls to the debounce handler will not call the handler until there are + * no new calls to the debounce handler for timeout_ms time. + * + * Example: + * function add(x, y) { return x + y; } + * let debounce_handler = debounce(add, 5000); + * let res; + * for (let i = 0; i < 10; i++) { + * res = debounce_handler(i, 100); + * } + * console.log("Result:", res); + * + * This example will print "Result: 109". + */ + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => handler(...args), timeout_ms); + }; +}; + +const waitForElement = selector => { + /** Promise that waits for an element to exist in DOM. */ + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver(mutations => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true + }); + }); +}; + +const waitForBool = o => { + /** Promise that waits for a boolean to be true. + * + * `o` must be an Object of the form: + * { state: } + * + * Resolves when (state === true) + */ + return new Promise(resolve => { + (function _waitForBool() { + if (o.state) { + return resolve(); + } + setTimeout(_waitForBool, 100); + })(); + }); +}; + +const waitForKeyInObject = o => { + /** Promise that waits for a key to exist in an object. + * + * `o` must be an Object of the form: + * { + * obj: , + * k: , + * } + * + * Resolves when (k in obj) + */ + return new Promise(resolve => { + (function _waitForKeyInObject() { + if (o.k in o.obj) { + return resolve(); + } + setTimeout(_waitForKeyInObject, 100); + })(); + }); +}; + +const waitForValueInObject = o => { + /** Promise that waits for a key value pair in an Object. + * + * `o` must be an Object of the form: + * { + * obj: , + * k: , + * v: + * } + * + * Resolves when obj[k] == v + */ + return new Promise(resolve => { + waitForKeyInObject({k: o.k, obj: o.obj}).then(() => { + (function _waitForValueInObject() { + + if (o.k in o.obj && o.obj[o.k] == o.v) { + return resolve(); + } + setTimeout(_waitForValueInObject, 100); + })(); + }); + }); +}; + +/** 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) { + /** Converts an HTML string into an Element type. */ + let parser = new DOMParser(); + let tmp = parser.parseFromString(str, "text/html"); + return tmp.body.firstElementChild; +}; \ No newline at end of file diff --git a/modules/ui_gradio_extensions.py b/modules/ui_gradio_extensions.py index f5278d22f..71ad13b73 100644 --- a/modules/ui_gradio_extensions.py +++ b/modules/ui_gradio_extensions.py @@ -16,7 +16,14 @@ def javascript_html(): script_js = os.path.join(script_path, "script.js") head += f'\n' - for script in scripts.list_scripts("javascript", ".js"): + # We want the utils.js script to be imported first since it can be used by multiple other + # scripts. This resolves dependency issues caused by html \n' + + # Now add all remaining .js scripts excluding the utils.js file. + for script in [x for x in js_scripts if x.filename != "utils.js"]: head += f'\n' for script in scripts.list_scripts("javascript", ".mjs"):